Request-scoped cancellation in Go: context boundaries for HTTP services
Create one bounded request context at HTTP ingress, pass it as the first argument, derive short-lived children for sub-operations, and never store context in long-lived structs to avoid leaked goroutines.
16 Aug 2026, 21:07 UTC

Leaked work after client disconnect is the problem
An HTTP handler that starts a goroutine or calls a downstream service without wiring the request lifecycle keeps running after the client times out or closes the connection. The useful takeaway is to create one bounded request context at ingress, pass it as the first argument through the call graph, and derive short-lived children for sub-operations. Do not store context in long-lived structs.
Requirements
- Propagate cancellation and deadlines from the request entrypoint to all downstream calls.
- Prevent leaked goroutines when the client disconnects or a deadline is hit.
- Keep request-scoped metadata isolated per invocation without globals.
The context must be created per request, bounded, and never reused across requests.
Smallest suitable design
Create a per-request context at the handler entry with a bounded timeout derived from the incoming request deadline. Pass context as the first parameter through the call chain. Derive child contexts with context.WithTimeout or context.WithCancel for sub-operations.
func handleGet(w http.ResponseWriter, r *http.Request) {
// Bound the whole request; do not use context.Background here.
reqCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
data, err := fetchUser(reqCtx, "user-123")
if err != nil {
if errors.Is(reqCtx.Err(), context.DeadlineExceeded) {
// emit deadline metric here
}
http.Error(w, "failed", http.StatusGatewayTimeout)
return
}
// ...
}
func fetchUser(ctx context.Context, id string) (*User, error) {
// Child with tighter budget for this dependency.
child, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
return db.Get(child, id)
}
Rules: context is the first argument, never stored in a struct that outlives the request, and every spawned goroutine receives a derived context and selects on ctx.Done.
Trust and data boundaries
Treat context.Value data as untrusted metadata. Validate on read and never pass secrets or large payloads via context. Use an unexported key type per package to avoid collisions.
type ctxKey struct{ name string }
var requestIDKey = ctxKey{name: "request_id"}
Enforce a clear boundary at ingress where the context is created from r.Context() and at egress where outgoing calls receive a derived context. Do not use context for general parameter passing; prefer explicit function arguments for business data.
Timeout values must be bounded and consistent across layers to avoid cascading failures and thundering herd on downstream services.
Operational checks
Emit counters for context deadline exceeded and canceled, track in-flight requests and goroutine count, and log handler exits that are due to ctx.Done.
Verification steps you can run in a staging environment with permission to hit admin endpoints:
- Inspect handler code to confirm a request context is derived with a timeout from the incoming request deadline and passed as the first argument through the call graph.
- Run a sustained load with a slow dependency and verify goroutine count remains stable after traffic stops and deadline exceeded metrics increase as expected. Risk: load tests can impact shared dependencies.
- Review goroutine spawn sites to ensure each receives a derived context and selects on ctx.Done, and that no background context is used inside request handling.
A practical check for goroutine growth is to expose pprof on a non-production port and compare snapshots before and after a timeout burst. Requires read access to the service process and exposes internal state.
Failure modes to avoid
- Ignoring ctx in select or downstream calls causes leaks.
- Using context.Background inside a handler defeats timeouts.
- Retaining context in structs causes memory retention because the context holds references to the request.
- Key collisions from exported value keys lead to accidental overwrites.
Context values are not type-safe. Use unexported key types per package.
Conditions that would change the design
Need for distributed tracing or multi-tenant cancellation scopes requires explicit propagation of trace IDs and separate cancellation trees. Moving to asynchronous worker queues may require decoupling request context from processing context, because the request may finish before the work does. In that case create a processing context with its own deadline and do not carry the HTTP request context into the queue.
Limitations: context does not provide backpressure, and deadlines are only effective if every callee respects ctx.Done. The design assumes synchronous request handling. For long-running background jobs, use a separate context tree.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.