71. The production server

📖 Reading · 14 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Everything so far has been handlers driven directly, with no server underneath. This lesson puts one there — and then does the part most tutorials skip: stops it without dropping anyone's request.

The one-liner, and why it isn't enough

http.ListenAndServe(":8080", mux)

That works, and you should not ship it. It gives you a server with no timeouts at all, which means a client can open a connection, send one byte of a request header, and hold that connection open forever. A few thousand of those and your service is out of file descriptors without a single real request being served. It has a name — Slowloris — and the defence is configuration you can't reach through ListenAndServe.

Build the server yourself instead:

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       10 * time.Second,
    WriteTimeout:      10 * time.Second,
    IdleTimeout:       60 * time.Second,
}
field bounds
ReadHeaderTimeout time to send request headers — the Slowloris defence
ReadTimeout time to send headers and body
WriteTimeout time from end of headers to the end of your response
IdleTimeout how long a keep-alive connection may sit unused

The numbers depend on your traffic. An API serving small JSON wants them tight; a service accepting file uploads needs a generous ReadTimeout, and one streaming large downloads needs WriteTimeout long enough for the slowest client you're willing to support — or zero, meaning no limit, with the per-request deadline handled in the handler instead.

The rule: set them all deliberately. The zero value of every one of these is "no limit", so the default is not a conservative choice, it's the absence of a choice.

The lifecycle

package main

import (
    "context"
    "errors"
    "fmt"
    "io"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "ok")
    })

    srv := &http.Server{Addr: "127.0.0.1:8080", Handler: mux}

    serveErr := make(chan error, 1)
    go func() { serveErr <- srv.ListenAndServe() }()
    time.Sleep(50 * time.Millisecond)

    resp, err := http.Get("http://127.0.0.1:8080/health")
    if err != nil {
        fmt.Println("request failed:", err)
        return
    }
    body, _ := io.ReadAll(resp.Body)
    resp.Body.Close()
    fmt.Printf("while running: %d %q\n", resp.StatusCode, string(body))

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        fmt.Println("shutdown failed:", err)
        return
    }
    fmt.Println("shutdown returned cleanly")

    err = <-serveErr
    fmt.Println("ListenAndServe returned:", err)
    fmt.Println("is ErrServerClosed:", errors.Is(err, http.ErrServerClosed))
}
while running: 200 "ok"
shutdown returned cleanly
ListenAndServe returned: http: Server closed
is ErrServerClosed: true

That really is a server, listening on a port, answering a real HTTP request — in your browser tab. Go's WebAssembly build ships an in-process network, so client and server can find each other inside the one program. Nothing here reaches the outside world, but the code is identical to what you'd deploy.

Three things to take from it:

ListenAndServe blocks until the server stops, so it goes in a goroutine whenever you need to do anything else afterwards — and shutdown is exactly that.

It never returns nil. When you shut down deliberately it returns http.ErrServerClosed, which is a success. Code that treats any non-nil error as a crash will report a clean shutdown as a failure:

if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
    log.Error("server failed", "err", err)   // a real failure: port in use, etc.
}

Send the error somewhere. The buffered channel means the goroutine can finish even if nobody reads it, and main can distinguish "shut down cleanly" from "port 8080 was already taken".

What graceful shutdown actually does

Shutdown stops accepting new connections, then waits for in-flight requests to finish. That second half is the whole point:

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(300 * time.Millisecond)
        fmt.Fprint(w, "slow work finished")
    })

    srv := &http.Server{Addr: "127.0.0.1:8081", Handler: mux}
    go srv.ListenAndServe()
    time.Sleep(50 * time.Millisecond)

    done := make(chan string, 1)
    go func() {
        resp, err := http.Get("http://127.0.0.1:8081/slow")
        if err != nil {
            done <- "request failed: " + err.Error()
            return
        }
        b, _ := io.ReadAll(resp.Body)
        resp.Body.Close()
        done <- string(b)
    }()

    time.Sleep(100 * time.Millisecond)
    fmt.Println("shutdown starting while a request is in flight")

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    start := time.Now()
    srv.Shutdown(ctx)
    fmt.Println("shutdown returned after waiting:", time.Since(start) > 100*time.Millisecond)
    fmt.Println("in-flight request got:", <-done)
}
shutdown starting while a request is in flight
shutdown returned after waiting: true
in-flight request got: slow work finished

Shutdown was called 100ms into a 300ms request, and it waited — the client got a complete, correct response. Without this, that client gets a connection reset and, if the handler was charging a card, no idea whether it worked.

The other half:

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "ok")
    })

    srv := &http.Server{Addr: "127.0.0.1:8082", Handler: mux}
    go srv.ListenAndServe()
    time.Sleep(50 * time.Millisecond)

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    srv.Shutdown(ctx)

    _, err := http.Get("http://127.0.0.1:8082/")
    fmt.Println("request after shutdown succeeded:", err == nil)
}
request after shutdown succeeded: false

Old requests finish; new ones are refused. That combination is what lets a load balancer move traffic away from an instance while it drains.

The context on Shutdown is the deadline for draining. If it expires first, Shutdown returns the context's error and stops waiting — in-flight requests are abandoned. Pick a timeout longer than your slowest legitimate request but shorter than your orchestrator's patience: Kubernetes sends SIGKILL after its grace period regardless, so a 60-second drain under a 30-second grace period just means you get killed mid-drain anyway.

The real main

Nothing above triggers shutdown from outside the process. In production the trigger is a signal — SIGTERM when a container is asked to stop, SIGINT from Ctrl-C:

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    go func() {
        if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
            log.Error("server failed", "err", err)
            stop()
        }
    }()
    log.Info("listening", "addr", srv.Addr)

    <-ctx.Done()            // blocks until SIGINT/SIGTERM
    log.Info("shutting down")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Error("graceful shutdown failed", "err", err)
        srv.Close()         // last resort: drop everything
    }
    log.Info("stopped")
}

That's the shape worth memorising — about twenty lines, and it's most of what "production-ready" means for a Go HTTP service.

signal.NotifyContext is the tidy modern spelling: it returns a context that cancels when one of the named signals arrives, so waiting for a shutdown signal is just <-ctx.Done() and the same context can be handed to anything else that should stop at the same time.

That box is reference-only here — it waits for a signal that will never arrive in a browser tab, and syscall isn't available in the in-browser interpreter at all. Everything else in this lesson runs.

srv.Close() is the ungraceful sibling: it drops connections immediately. It's the right call only after a graceful attempt has already failed.

Your turn

Put the pieces together. Build a server on 127.0.0.1:8083 with all four timeouts set, serving GET /pingpong. Start it in a goroutine, shut it down gracefully, and print server stopped cleanly if the serve error is http.ErrServerClosed — or unexpected server error: <err> if it's anything else.

package main

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /ping", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "pong")
    })

    // build an http.Server on 127.0.0.1:8083 with the four timeouts

    // start it in a goroutine, sending the error to a buffered channel
    time.Sleep(50 * time.Millisecond)

    // shut down with a 5s context, then report:
    //   "server stopped cleanly"        if the serve error is ErrServerClosed
    //   "unexpected server error: <err>" otherwise
}
package main

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /ping", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "pong")
    })

    srv := &http.Server{
        Addr:              "127.0.0.1:8083",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    serveErr := make(chan error, 1)
    go func() { serveErr <- srv.ListenAndServe() }()
    time.Sleep(50 * time.Millisecond)

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        fmt.Println("shutdown error:", err)
        return
    }

    err := <-serveErr
    if errors.Is(err, http.ErrServerClosed) {
        fmt.Println("server stopped cleanly")
        return
    }
    fmt.Println("unexpected server error:", err)
}

Next: the capstone — a complete service, with the tests that keep it honest.