Skip to content
Home
Go HTTP Server: Building Web Servers from Scratch

Go HTTP Server: Building Web Servers from Scratch

7 min read

Go’s standard library includes everything you need to build production web servers — no frameworks required. The net/http package provides an HTTP client and server implementation that’s fast, well-tested, and idiomatic.

The Basics: Hello World Server

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })

    http.ListenAndServe(":8080", nil)
}

That’s it. Run go run main.go and visit http://localhost:8080. The server handles connections, parses requests, and sends responses using the standard library.

Handlers and Handler Functions

In Go, an HTTP handler implements the http.Handler interface:

type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

http.HandlerFunc is a function type that implements Handler by calling itself:

type HandlerFunc func(w http.ResponseWriter, r *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

This means any function with the signature func(w http.ResponseWriter, r *http.Request) can be used as a handler.

Route Matching

Go’s default ServeMux (multiplexer) matches the longest prefix:

http.HandleFunc("/", homePage)        // catches everything
http.HandleFunc("/api", apiHome)      // catches /api, /api/anything
http.HandleFunc("/api/users", users)  // catches /api/users, /api/users/123

Patterns ending with / are subtree patterns — they match any path starting with that prefix. Patterns without a trailing / are exact matches.

// Exact match — only "/about"
http.HandleFunc("/about", aboutPage)

// Subtree match — "/api/" and anything under it
http.HandleFunc("/api/", apiHandler)

Go 1.22 introduced enhanced routing with method matching and path parameters:

mux := http.NewServeMux()
mux.HandleFunc("GET /api/users/{id}", getUser)
mux.HandleFunc("POST /api/users", createUser)
mux.HandleFunc("PUT /api/users/{id}", updateUser)
mux.HandleFunc("DELETE /api/users/{id}", deleteUser)

// Access path parameters
func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "User ID: %s", id)
}

The Request and Response

Reading the Request

func handler(w http.ResponseWriter, r *http.Request) {
    // Method
    fmt.Println(r.Method)

    // URL components
    fmt.Println(r.URL.Path)
    fmt.Println(r.URL.Query().Get("page"))

    // Headers
    fmt.Println(r.Header.Get("Content-Type"))

    // Body
    body, _ := io.ReadAll(r.Body)
    defer r.Body.Close()
    fmt.Println(string(body))

    // Form data
    r.ParseForm()
    fmt.Println(r.FormValue("username"))

    // JSON
    var data map[string]interface{}
    json.NewDecoder(r.Body).Decode(&data)
}

Writing the Response

func jsonResponse(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)

    response := map[string]string{"status": "ok", "message": "Hello, JSON!"}
    json.NewEncoder(w).Encode(response)
}

func fileResponse(w http.ResponseWriter, r *http.Request) {
    // Serve a file
    http.ServeFile(w, r, "static/index.html")

    // Or serve from a file system
    fs := http.FileServer(http.Dir("static"))
    http.Handle("/static/", http.StripPrefix("/static/", fs))
}

Custom ServeMux and Routing

For complex routing, build a custom multiplexer:

type Router struct {
    routes []Route
}

type Route struct {
    Method  string
    Pattern string
    Handler http.HandlerFunc
}

func (r *Router) Handle(method, pattern string, handler http.HandlerFunc) {
    r.routes = append(r.routes, Route{Method: method, Pattern: pattern, Handler: handler})
}

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    for _, route := range r.routes {
        if route.Method != "" && req.Method != route.Method {
            continue
        }
        if match, _ := path.Match(route.Pattern, req.URL.Path); match {
            route.Handler(w, req)
            return
        }
    }
    http.NotFound(w, req)
}

Most projects use a third-party router like chi or gorilla/mux for advanced features, but the standard library is sufficient for many APIs.

Middleware

Middleware wraps a handler to add cross-cutting behavior — logging, authentication, rate limiting, recovery.

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if !isValidToken(token) {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("PANIC: %v", err)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// Chain middleware
func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", usersHandler)

    handler := Recovery(Logging(Auth(mux)))
    http.ListenAndServe(":8080", handler)
}

Middleware With Configuration

func RateLimit(requests int, per time.Duration) func(http.Handler) http.Handler {
    limiter := rate.NewLimiter(rate.Limit(requests)/per.Seconds(), requests)
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !limiter.Allow() {
                http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Static File Serving

Go can serve static files with a single line. For production, use the embed package to bundle files into the binary:

import "embed"

//go:embed static/*
var staticFiles embed.FS

func main() {
    fs := http.FileServer(http.FS(staticFiles))
    http.Handle("/static/", http.StripPrefix("/static/", fs))
}

For deployment, embedded files eliminate the need to manage separate static file directories. The binary contains everything needed to run.

HTTPS and TLS

Enable TLS by providing a certificate and key:

srv := &http.Server{
    Addr:    ":443",
    Handler: router,
}

log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))

For production, use Let’s Encrypt with golang.org/x/crypto/acme/autocert for automatic certificate renewal:

m := &autocert.Manager{
    Cache:      autocert.DirCache("certs"),
    Prompt:     autocert.AcceptTOS,
    HostPolicy: autocert.HostWhitelist("example.com"),
}

srv := &http.Server{
    Addr:      ":https",
    TLSConfig: &tls.Config{GetCertificate: m.GetCertificate},
}

Structured Logging

Add request logging with duration and status code tracking:

type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.status = code
    rw.ResponseWriter.WriteHeader(code)
}

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(rw, r)
        log.Printf("method=%s path=%s status=%d duration=%s",
            r.Method, r.URL.Path, rw.status, time.Since(start))
    })
}

Structured logging (key=value format) makes it easy to search logs and integrate with log aggregation tools.

Graceful Shutdown

Production servers must shut down gracefully — finish in-flight requests, close connections, clean up resources:

func main() {
    srv := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %s\n", err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("server forced to shutdown: %v", err)
    }
}

Rate Limiting

Protect your server from abuse with a simple token bucket rate limiter:

func rateLimit(next http.Handler, requestsPerSec int) http.Handler {
    limiter := rate.NewLimiter(rate.Limit(requestsPerSec), 1)
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Combine rate limiting with IP-based tracking for per-client limits. Store token buckets per IP in a sync.Map or Redis for distributed deployments.

Testing HTTP Handlers

Go’s httptest package provides utilities for testing handlers without a running server:

func TestGreeting(t *testing.T) {
    req := httptest.NewRequest("GET", "/greet?name=Alice", nil)
    rec := httptest.NewRecorder()
    greetHandler(rec, req)

    if rec.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rec.Code)
    }

    expected := `{"message":"Hello, Alice"}`
    if rec.Body.String() != expected {
        t.Errorf("expected %q, got %q", expected, rec.Body.String())
    }
}

Production Configuration

srv := &http.Server{
    Addr:         ":8080",
    Handler:      router,
    ReadTimeout:  10 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  120 * time.Second,
    MaxHeaderBytes: 1 << 20,  // 1 MB
}

Summary

Go’s standard library provides a complete HTTP server framework. Handlers and middleware compose cleanly through the http.Handler interface. With graceful shutdown, proper timeouts, and middleware chains, you can build production-ready web services without external dependencies.


Related: Go REST API Guide | Go Web Development

Advanced Patterns in Go

Context Propagation

Go’s context package is essential for managing timeouts, cancellations, and request-scoped values across API boundaries and goroutines:

func handler(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    result, err := queryDatabase(ctx)
    if err != nil {
        return fmt.Errorf("query failed: %w", err)
    }
    return result
}

Memory Management

Go’s garbage collector has been optimized significantly since version 1.5. The GC is concurrent and generational, with typical pause times under 1ms. For latency-critical applications, you can tune GC behavior with GOGC (target heap growth percentage) and GOMEMLIMIT (soft memory cap).

# Aggressive GC for latency-sensitive apps
export GOGC=50    # trigger GC when heap grows 50%
export GOMEMLIMIT=2GiB  # soft memory limit

Build Tags and Conditional Compilation

Build tags let you compile platform-specific or feature-flag code:

//go:build linux
// +build linux

package main

func getOS() string {
    return "linux"
}
go build -tags=debug,linux .

Pprof Profiling

import _ "net/http/pprof"

// Access profiles at /debug/pprof/
go tool pprof http://localhost:8080/debug/pprof/heap
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30

FAQ

Q: Do I need a third-party router for Go HTTP servers? A: No. Go 1.22+ ServeMux supports method-based routing and path parameters. Third-party routers like chi or gorilla/mux offer additional features but are not required.

Q: How do I set response headers in Go? A: Use w.Header().Set("Key", "Value") before calling w.WriteHeader(). Headers must be set before the first write.

Q: What timeouts should I set on a production server? A: Set ReadTimeout (10s), WriteTimeout (10s), IdleTimeout (120s), and MaxHeaderBytes (1MB). These prevent slow clients from consuming server resources.

Q: How do I handle CORS in Go? A: Set CORS headers (Access-Control-Allow-Origin, etc.) in a middleware handler. For preflight OPTIONS requests, return 204 with the appropriate headers.

Q: Can I serve HTTP and HTTPS on the same port? A: No. Use separate ports (e.g., 80 for HTTP redirect to HTTPS, 443 for HTTPS) or use a reverse proxy like Nginx or Caddy.

#go#http-server#handlers#middleware#web