71. The production server
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 /ping → pong. 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.