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.
Search across all documentation pages
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.
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.
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:
r.Context() as the first argument downstream.context.Canceled when the response cannot reach the client.WithTimeout only at intentional internal boundaries, not at the handler root.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:
r.Context(), not context.Background().QueryRowContext respects cancellation when the client aborts.context.Canceled and context.DeadlineExceeded short-circuit without a misleading 500.ReadHeaderTimeout complements per-handler budgets.http.Server creates a request context when it accepts a connection.ResponseWriter writes after cancel may fail silently; guard with context checks before expensive work.(w, r) and pass r.Context() into next.ServeHTTP without replacing it unless adding values or deadlines.| 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 |
| 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() |
// 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))
})
}r.Context().r.Context() or detach explicitly with context.WithoutCancel only for intentional async cleanup.ctx.Done() between chunks.defer r.Body.Close() in handlers that read bodies.| 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 |
On client disconnect, handler completion, or server timeout configuration.
Exact timing depends on http.Server fields and TLS layer behavior.
Wrap with WithValue, WithTimeout, or WithCancel and call r.WithContext(child).
Never substitute context.Background() mid-chain.
Use httptest plus a cancelable parent or close the recorder client.
See the testing article in this section.
c.Set stores gin-local keys; use c.Request.Context() for cancellation propagation.
It wraps handlers with a timeout context and returns 503 on expiry.
Still pass the wrapped context downstream.
Yes - http.NewRequestWithContext(r.Context(), ...) ties client lifetime to dependency calls.
Log at debug when errors.Is(err, context.Canceled) and a response was not started.
Avoid error-level noise for normal client behavior.
Only with context.WithoutCancel for intentional fire-and-forget tasks.
Default: stop work when the request ends.
Upgrade handlers still start from r.Context(); connection lifetime may outlive it.
Manage WS cancel with connection close signals separately.
It wraps r.Context() with a shorter deadline and cancels when exceeded.
Downstream must respect the wrapped ctx.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026