A Minimal Go HTTP Service with chi, Timeouts and Graceful Shutdown
Draining in-flight requests on SIGTERM instead of dropping them mid-response.
The problem
The smallest possible Go HTTP server is three lines:
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
It has no read timeout, no write timeout, no idle timeout, and nowhere obvious to attach
middleware like request logging or panic recovery — http.HandleFunc on the default mux
does not compose the way most people expect once there is more than one route. Without
ReadHeaderTimeout specifically, a client that opens a connection and sends headers one
byte at a time (deliberately or through a broken proxy) can hold a goroutine and a file
descriptor open indefinitely; this is common enough to have its own linter warning
(gosec’s G112).
The bigger, less obvious problem is shutdown. os.Signal is not handled at all by
default, so a SIGTERM — the signal Kubernetes sends on pod termination, the signal
docker stop sends before its grace period expires — kills the process immediately,
mid-request. Whatever request was in flight at that moment gets a reset connection instead
of a response, and the client’s retry logic (if it has any) is what saves you, not
anything the server did.
The fix looks straightforward — catch the signal, call Shutdown — but two details are
easy to get wrong: Shutdown blocks until every in-flight request is done or its own
context is cancelled, so calling it with no timeout at all means a single stuck handler
prevents the process from ever exiting; and the signal handling has to happen before
ListenAndServe, in a separate goroutine, or the program never reaches the code that
listens for the signal in the first place.
Working through it
Setting timeouts on the server, not per-handler
http.Server’s zero-value timeouts mean no timeout. ReadHeaderTimeout bounds how long a
client gets to finish sending headers; ReadTimeout bounds the whole request including
body; WriteTimeout bounds how long a handler has to write a response; IdleTimeout
bounds how long a keep-alive connection can sit unused. These are properties of the
server, not of any one route, which is why they belong on the http.Server struct rather
than scattered through handler code.
Choosing chi for routing, not for anything it does to shutdown
chi doesn’t change how graceful shutdown works — that is entirely http.Server’s job,
regardless of router. What it buys is composable middleware (chi.Middlewares) and a
router that doesn’t require a global mux, which matters once there is more than a couple
of routes and more than one thing (logging, recovery, request IDs) that should wrap all of
them.
Using a context to bound shutdown, and a second one to bound the signal wait
signal.NotifyContext produces a context that is cancelled the moment SIGTERM or
SIGINT arrives — that’s the trigger to start shutting down. Shutdown itself takes a
different context, one with its own timeout, that bounds how long the drain is allowed
to take before the server gives up and returns anyway. Using the same context for both
would mean the moment the signal arrives, the shutdown deadline is already expired.
Proving the drain actually happens, not just that the code compiles
A handler that sleeps briefly, combined with sending a shutdown signal partway through the
request and asserting the client still got a 200, is the only way to actually prove
in-flight requests survive shutdown rather than assuming the standard library does what
its documentation says.
The solution
// go.mod
module chi-graceful-shutdown
go 1.22
require github.com/go-chi/chi/v5 v5.1.0
// main.go
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func newServer(addr string, handler http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
func newRouter() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
r.Get("/slow", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
w.WriteHeader(http.StatusOK)
w.Write([]byte("done"))
})
return r
}
func run(ctx context.Context, addr string, shutdownTimeout time.Duration) error {
srv := newServer(addr, newRouter())
serveErr := make(chan error, 1)
go func() {
log.Printf("listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- err
return
}
serveErr <- nil
}()
select {
case err := <-serveErr:
return err
case <-ctx.Done():
log.Printf("shutdown signal received, draining (timeout %s)", shutdownTimeout)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
log.Println("shutdown complete")
return nil
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, ":8080", 15*time.Second); err != nil {
log.Fatalf("server error: %v", err)
}
}
// main_test.go
package main
import (
"context"
"net/http"
"testing"
"time"
)
func TestSlowRequestSurvivesShutdownSignal(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- run(ctx, ":18080", 5*time.Second)
}()
waitForServer(t, "http://localhost:18080/healthz")
respCh := make(chan *http.Response, 1)
reqErrCh := make(chan error, 1)
go func() {
resp, err := http.Get("http://localhost:18080/slow")
if err != nil {
reqErrCh <- err
return
}
respCh <- resp
}()
time.Sleep(300 * time.Millisecond) // let the slow request start
cancel() // simulate SIGTERM
select {
case err := <-reqErrCh:
t.Fatalf("in-flight request failed during shutdown: %v", err)
case resp := <-respCh:
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
case <-time.After(5 * time.Second):
t.Fatal("in-flight request never completed")
}
if err := <-errCh; err != nil {
t.Fatalf("run returned an error: %v", err)
}
}
func waitForServer(t *testing.T, url string) {
t.Helper()
for i := 0; i < 50; i++ {
if resp, err := http.Get(url); err == nil {
resp.Body.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("server never became ready")
}
Running it:
go mod tidy
go build ./...
go run . &
curl -s localhost:8080/healthz
# ok
curl -s localhost:8080/slow &
sleep 1
kill -TERM %1
# 2025-10-21T10:00:00Z shutdown signal received, draining (timeout 15s)
# 2025-10-21T10:00:02Z shutdown complete
# done <- curl still gets its response before the process exits
go test -v ./...
# --- PASS: TestSlowRequestSurvivesShutdownSignal (3.31s)
# PASS
The curl -s localhost:8080/slow & request, started before the kill -TERM, is the part
worth watching closely: it prints done after the shutdown log line, proving the
process kept the listener’s already-accepted connection alive to finish it, rather than
tearing it down the instant the signal arrived.
Conclusion
Nothing here is specific to chi — the graceful shutdown sequence is net/http and
context, and would look identical with the standard library’s own http.NewServeMux.
chi earns its place only once middleware composition matters.
Three points generalise past this one service:
Server-level timeouts are not optional once the service leaves your laptop. A client you do not control, on a network you do not control, will eventually be the slow or malicious client that a zero-value timeout leaves your server exposed to indefinitely.
Shutdown needs two independent time bounds, not one. When you receive the signal to stop, and how long you are willing to wait once stopping, are different questions with different answers, and conflating them into one context makes the timeout mean something other than what its name suggests.
A signal handling test that never sends a signal to the process hasn’t tested the
signal handling. Driving run() with a cancellable context and checking the in-flight
request’s actual outcome is what makes this test worth more than “the code compiles and
the happy path returns 200”.