Why Your Go HTTP Handlers Should Carry Their Own Deadline
Go handlers that outlive their clients leak goroutines and connections. context.WithTimeout bounds the damage — if you propagate it correctly and pick the deadline from real latency data.
29 Sept 2025, 13:18 UTC

Here's a failure mode that shows up in Go services more often than it should: a handler calls a slow database, the client gets bored and disconnects, and the handler keeps working anyway. The goroutine is still running, still holding a connection, still chewing CPU — for a request nobody will ever read the answer to. Multiply that by a few thousand abandoned requests and you have a resource leak that looks mysterious until you count goroutines.
The fix is boring and reliable: give every request a deadline with context.WithTimeout, and make sure that deadline actually reaches the work. This post covers the pattern, a worked example, and the one mistake that silently defeats it.
What WithTimeout actually buys you
Every incoming HTTP request in Go already carries a context on *http.Request, and the standard library cancels it when the client disconnects. What it doesn't do is cap how long your handler may run. context.WithTimeout wraps a parent context and cancels the child once a duration elapses — whichever comes first, the timeout or the parent cancelling.
Any blocking operation that respects contexts (database drivers, net/http clients, gRPC calls) will return promptly with ctx.Err() set to context.DeadlineExceeded once the deadline passes. The API has been stable since Go 1.7, so this works identically on any modern toolchain.
A worked example
Wrap the request context at the top of the handler, then pass the derived context to everything downstream:
func getUserHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() // releases the timer even on success
id := r.PathValue("id")
var name string
err := db.QueryRowContext(ctx,
"SELECT name FROM users WHERE id = $1", id).Scan(&name)
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
http.Error(w, "upstream too slow", http.StatusGatewayTimeout)
return
}
http.Error(w, "lookup failed", http.StatusInternalServerError)
return
}
fmt.Fprintln(w, name)
}
}Three details matter here. First, defer cancel() is not optional — without it the timer goroutine lives until the deadline even when the handler finishes in 20 ms. Second, the derived ctx is passed to QueryRowContext, not the original r.Context(). Third, the error branch distinguishes a deadline from a genuine query failure, which makes your logs and status codes honest.
The pitfall: a deadline that never arrives
The most common way this pattern fails is propagation. If a handler spawns a goroutine and passes it context.Background() — or captures nothing at all — that goroutine ignores the timeout entirely. The handler returns a 504, the client moves on, and the background work keeps running. From the outside the service looks fine; internally goroutines accumulate until you hit memory pressure or connection pool exhaustion.
The rule of thumb: a context created inside a handler may flow down the call stack, but never up into long-lived state. If you genuinely need work to outlive the request (say, writing an audit record after responding), detach deliberately with context.WithoutCancel (Go 1.21+) and give that work its own timeout — don't just drop cancellation by accident.
Choosing the deadline, and the trade-off
The deadline is a business decision wearing a technical costume. Too short and you start failing legitimate requests under normal load — a p99 query that takes 6 s will never succeed behind a 5 s timeout, and your error rate becomes a function of traffic, not correctness. Too long and you've re-created the original problem: slow dependencies pin resources for minutes.
A practical approach is to look at your dependency's latency distribution, set the timeout a comfortable margin above p99, and let an outer layer (a reverse proxy or load balancer) enforce the absolute ceiling. Also remember that nested timeouts compose: if your handler allows 5 s and the database call gets its own 3 s context, the tighter one wins. That's usually what you want, but be deliberate about it.
How to verify it actually works
Don't trust the pattern; test it. A focused unit test can block a fake dependency on a channel, cancel the context, and assert the call returns context.DeadlineExceeded:
func TestHandlerRespectsDeadline(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := slowLookup(ctx) // blocks on <-ctx.Done() internally
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected DeadlineExceeded, got %v", err)
}
}Run the suite with go test -race and, for leak detection, compare runtime.NumGoroutine() before and after a batch of timed-out requests — or pull in go.uber.org/goleak to fail tests that leave goroutines behind. In production, a request-duration histogram should show a hard ceiling at your deadline; if you see durations above it, something downstream is ignoring the context.
Closing
context.WithTimeout is a two-line change that converts an unbounded failure mode into a bounded one. Add it at the handler boundary, propagate the derived context everywhere, defer the cancel, and pick the number from measured latency rather than vibes. Then write the test that proves the deadline fires — because the version of this bug you don't test is the one that leaks goroutines at 3 a.m.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.