Cancellation Propagation in HTTP Handlers
Every HTTP handler should treat r.Context() as the root of its work tree and pass it unchanged to downstream I/O.
When the client disconnects or the server enforces a timeout, that context cancels and cooperative callees should stop.
Summary
net/http attaches a per-request context.Context to each *http.Request.
Handlers pass r.Context() into database queries, outbound HTTP calls, and gRPC stubs so abandoned requests do not keep consuming resources.
Framework wrappers in chi, gin, and echo still expose the same underlying request context.
Recipe
Quick-reference recipe card - copy-paste ready.
func usersHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
users, err := store.ListUsers(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return // client gone; do not write 500
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(users)
}When to reach for this:
- Any handler that calls blocking APIs should thread
r.Context()as the first argument downstream. - Return quietly on
context.Canceledwhen the response cannot reach the client. - Wrap with shorter
WithTimeoutonly at intentional internal boundaries, not at the handler root.
Working Example
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"time"
_ "github.com/mattn/go-sqlite3"
)
type Store struct{ db *sql.DB }
func (s *Store) SlowQuery(ctx context.Context) (string, error) {
var out string
err := s.db.QueryRowContext(ctx, `SELECT 'ok'`).Scan(&out)
return out, err
}
func handler(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
val, err := store.SlowQuery(ctx)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"status": val})
}
}
func main() {
db, _ := sql.Open("sqlite3", ":memory:")
defer db.Close()
mux := http.NewServeMux()
mux.Handle("/api", handler(&Store{db: db}))
srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(srv.ListenAndServe())
}What this demonstrates:
- Handler derives a bounded child from
r.Context(), notcontext.Background(). QueryRowContextrespects cancellation when the client aborts.context.Canceledandcontext.DeadlineExceededshort-circuit without a misleading 500.- Server-level
ReadHeaderTimeoutcomplements per-handler budgets.
Deep Dive
How It Works
http.Servercreates a request context when it accepts a connection.- The context cancels when the client closes the socket, the handler returns, or server-level timeouts fire.
ResponseWriterwrites after cancel may fail silently; guard with context checks before expensive work.- Middleware should accept
(w, r)and passr.Context()intonext.ServeHTTPwithout replacing it unless adding values or deadlines.
Framework Notes
| Framework | Access request ctx | Middleware pattern |
|---|---|---|
| net/http | r.Context() | func(next http.Handler) http.Handler |
| chi | r.Context() | middleware.Timeout wraps child ctx |
| gin | c.Request.Context() | c.Request.WithContext(ctx) to replace |
| echo | c.Request().Context() | middleware.TimeoutWithConfig |
Propagation Checklist
| Layer | Rule |
|---|---|
| Handler | Start from r.Context() |
| Service | Accept ctx first param |
| SQL | Use QueryContext, ExecContext |
| Outbound HTTP | http.NewRequestWithContext |
| gRPC | Stub methods accept ctx |
| Goroutines | Pass ctx; stop on Done() |
Go Notes
// Middleware that adds a request ID value - still preserves parent cancel
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.NewString()
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Gotchas
- Replacing with Background in handlers - Loses client disconnect signals. Fix: always root at
r.Context(). - Writing 500 on context.Canceled - Client is gone; error responses waste work. Fix: return early without writing or log at debug.
- Spawning goroutines without ctx - Background work survives request end. Fix: pass
r.Context()or detach explicitly withcontext.WithoutCancelonly for intentional async cleanup. - Middleware timeout shorter than downstream - Inner calls fail while outer still runs. Fix: document timeout order; inner budgets must fit inside middleware.
- Ignoring ctx in streaming handlers - Flusher loops block after disconnect. Fix: select on
ctx.Done()between chunks. - Not closing request body - Cancels may delay connection reuse. Fix:
defer r.Body.Close()in handlers that read bodies.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Server BaseContext | Process-wide values on all requests | Per-request cancel (use request ctx) |
context.WithoutCancel | Audit logging after response | Normal handler I/O |
Manual done channel | Legacy code | New HTTP handlers |
| Short handler-only timeout | Protect single slow query | Replacing entire request ctx |
| Worker queue decoupled from HTTP | Async jobs outlive request | User waits for synchronous result |
FAQs
When does r.Context() cancel?
On client disconnect, handler completion, or server timeout configuration.
Exact timing depends on http.Server fields and TLS layer behavior.
Should middleware replace or wrap context?
Wrap with WithValue, WithTimeout, or WithCancel and call r.WithContext(child).
Never substitute context.Background() mid-chain.
How do I test cancellation?
Use httptest plus a cancelable parent or close the recorder client.
See the testing article in this section.
Does gin's c.Set affect context?
c.Set stores gin-local keys; use c.Request.Context() for cancellation propagation.
What about http.TimeoutHandler?
It wraps handlers with a timeout context and returns 503 on expiry.
Still pass the wrapped context downstream.
Should outbound calls use r.Context()?
Yes - http.NewRequestWithContext(r.Context(), ...) ties client lifetime to dependency calls.
How do I log when clients disconnect?
Log at debug when errors.Is(err, context.Canceled) and a response was not started.
Avoid error-level noise for normal client behavior.
Can I extend context after the handler returns?
Only with context.WithoutCancel for intentional fire-and-forget tasks.
Default: stop work when the request ends.
Do WebSockets use the same context?
Upgrade handlers still start from r.Context(); connection lifetime may outlive it.
Manage WS cancel with connection close signals separately.
How does chi Timeout middleware work?
It wraps r.Context() with a shorter deadline and cancels when exceeded.
Downstream must respect the wrapped ctx.
Related
- context Basics - WithCancel and WithTimeout primitives
- Deadlines & Timeouts Across Service Boundaries - multi-hop budgets
- context in database/sql & gRPC - driver and RPC cancel
- Testing Code That Accepts context.Context - httptest patterns
- context Package Best Practices - handler conventions
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).