Go context.Context Cheat Sheet: Cancellation, Timeouts & Gotchas
A handler returns 200, and the goroutine it spawned never dies. Nobody called
defer cancel(), so the goroutine outlives the request. Multiply by 10,000 requests per second and the process leaks goroutines until it's OOMKilled. It's the single most common goroutine leak there is.
The five constructors you need
[Go context]Every context.Context tree starts with one of two roots. Everything else is a child wrapper.
Background() is your tree root. WithCancel, WithTimeout, and WithDeadline leak without defer cancel(). Never pass nil, store context in a struct, or use Value for function arguments.
- Always pair
WithCancel/Timeout/Deadlinewithdefer cancel()to prevent leaks - Use
WithTimeoutfor RPC calls;WithDeadlinefor splitting parent budgets - Pass context as the first parameter; never cache it in a struct
graph TD
BG["context.Background()"] --> WC["WithCancel(bg)"]
BG --> WT["WithTimeout(bg, 5s)"]
WC --> WV["WithValue(wc, key, val)"]
WC -->|"spawns"| G1["goroutine A<br/>← ctx.Done()"]
WT -->|"spawns"| G2["goroutine B<br/>← ctx.Done()"]
WV -->|"spawns"| G3["goroutine C<br/>ctx.Value(key)"]
Constructor reference
ctx := context.Background() // root, never cancelled
ctx := context.TODO() // placeholder, don't ship
ctx, cancel := context.WithCancel(parent) // manual cancellation
ctx, cancel := context.WithTimeout(parent, 2*time.Second) // relative deadline
ctx, cancel := context.WithDeadline(parent, absoluteTime) // absolute deadline
ctx := context.WithValue(parent, key, val) // request-scoped valueBackground() is the root in main, tests, and background workers. TODO() signals refactoring—never deploy it. All cancel-returning constructors must have defer cancel() on the next line. Missed cancel() leaks a goroutine per request.
How cancellation propagates
A context is a tree. When a parent cancels, every descendant sees Done() close simultaneously — one signal fans out across every goroutine that branched off.
graph TD
A["handler<br/>Background()"] --> B["WithTimeout 2s<br/>client.Get()"]
A --> C["WithCancel<br/>spawner"]
C --> D["db.Query()"]
C --> E["cache.Set()"]
C --> F["publish()"]
B -. deadline fires .-> X(("Done() closes<br/>this subtree"))
C -. cancel() called .-> Y(("Done() closes<br/>all 3 goroutines"))
Every leaf goroutine must watch <-ctx.Done() alongside its real work. Otherwise cancellation can't reach it and the goroutine leaks.
Cancellation and timeouts
Use WithCancel for explicit cancellation. Use WithTimeout for RPC calls (2s budget). Use WithDeadline when splitting a parent budget across sequential calls:
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
resp, err := client.Get(ctx, "/orders/42")
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusGatewayTimeout, nil
}A mistake that recurs in production: never create a fresh context.Background() with WithTimeout inside a request handler—you orphan the request context and lose client disconnect signals.
Request-scoped values
WithValue stores per-request data (request IDs, trace spans, user) across API boundaries. Not for function arguments.
type ctxKey int
const requestIDKey ctxKey = iota
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestID(ctx context.Context) string {
v, _ := ctx.Value(requestIDKey).(string)
return v
}Always use a private unexported key type (not string). Exported keys collide. Type-assert; never assume presence.
HTTP and gRPC integration
[Go net/http]HTTP handlers get a request context via r.Context(). Wrap it with WithTimeout for your I/O budget:
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT id FROM orders WHERE user_id=$1", userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
}For outbound HTTP, use http.NewRequestWithContext(ctx, ...). For gRPC, methods take ctx as the first parameter. Deadlines propagate as grpc-timeout headers automatically.
Common gotchas
- Store context in a struct — Contexts live per-call, not per-object[Go context]. Pass it explicitly to every method.
- Pass
nilcontext — Causes a panic. Usecontext.TODO()as a placeholder. - Forget
defer cancel()—WithCancel,WithTimeout,WithDeadlineleak if not cleaned up[Go context]. - Use
Valuefor optional arguments — Unreadable and untyped. Pass as a regular function parameter. - Ignore
ctx.Err()after<-ctx.Done()—Done()fires for both cancellation and timeout. Callctx.Err()to distinguish:context.Canceled→ 499 (client gone),DeadlineExceeded→ 504 (too slow).
Detecting context leaks in tests and production
The runtime.NumGoroutine() count is the cheap canary. Sample it before and after a test; non-zero delta = leak. The goleak package automates this:
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
// per-test guard — fails the test if any goroutine outlives this function
func TestProcessOrder(t *testing.T) {
defer goleak.VerifyNone(t)
// ... test body
}In production, client_golang's default Go collector already exports go_goroutines for free — scrape that. To expose the count yourself (e.g. a custom registry without that collector), use a GaugeFunc with a name that can't collide with the built-in:
var goroutines = promauto.NewGaugeFunc(
prometheus.GaugeOpts{Name: "app_goroutines_live"},
func() float64 { return float64(runtime.NumGoroutine()) },
)Alert when the goroutine count grows monotonically over an hour — that pattern is almost always a forgotten defer cancel() or a goroutine waiting on a channel that nobody closes.
Cancellation propagation rules
A few rules that catch >90% of real-world bugs: [Go context]
- Always pass
ctxas the first parameter of any function that does I/O, blocks on a channel, or calls anotherctx-aware function. - Never wrap
ctxinside another struct with the goal of "saving keystrokes." Thecontainedctxlinter flags acontext.Contextheld in a struct field. (staticcheckSA1029 is a different check — it flags built-in types used asWithValuekeys, the rule behind the unexported-key-type advice above.) select { case <-ctx.Done(): }everywhere that you would otherwise block. Aforloop reading from a channel becomes:
for {
select {
case <-ctx.Done():
return ctx.Err()
case msg := <-msgs:
if err := process(ctx, msg); err != nil {
return err
}
}
}- Cancel-on-parent:
WithCancel(parent)cancels whenparentcancels — wire your tree so one root cancellation drains the whole stack. - Don't recreate roots inside the request path.
context.Background()belongs inmain()and a few cron entry points; everything downstream derives from the requestctx.
What ctx.Err() returns and why it matters
After <-ctx.Done() fires, call ctx.Err() to know why the context cancelled:
| Return value | Trigger | HTTP mapping | gRPC mapping |
|---|---|---|---|
nil | Context still live — Done() not yet closed (a closed Done() always yields a non-nil Err()) | n/a | n/a |
context.Canceled | Caller invoked cancel() or parent canceled | 499 (client closed request) | codes.Canceled |
context.DeadlineExceeded | Deadline reached (WithTimeout, WithDeadline) | 504 (gateway timeout) | codes.DeadlineExceeded |
The HTTP 499 status code is non-standard but supported by Nginx and most observability tools[Go context]. Use it to distinguish "client gave up" (499) from "we ran out of budget" (504) — both metrics matter for SLO tracking, and conflating them masks real availability problems.
For Go 1.20+, WithCancelCause(parent) and context.Cause(ctx) let you attach an error to the cancellation, which propagates through the chain. Use it sparingly — most code paths only care about the canceled-vs-timed-out distinction above.
Pick the right constructor for the symptom
When a context bug bites in production, the question is "what kind of cancellation do I want." Route by intent, not by API name:
graph TD
Need[I need to control<br/>a downstream operation] --> Q{Why am I<br/>cancelling?}
Q -->|Caller might give up| Cancel[WithCancel<br/>+ defer cancel<br/>good for fan-out]
Q -->|Operation has SLA| Timeout[WithTimeout<br/>+ defer cancel<br/>500ms RPC budget]
Q -->|Need budget split<br/>across multiple steps| Deadline[WithDeadline<br/>+ defer cancel<br/>parent budget minus elapsed]
Q -->|Carrying request data<br/>not cancellation| Value[WithValue<br/>request ID, user ID,<br/>trace span]
Q -->|Multiple cancellation<br/>causes| Cause[WithCancelCause<br/>Go 1.20 plus<br/>read with context.Cause]
Cancel --> Always[Always call cancel<br/>even on success path]
Timeout --> Always
Deadline --> Always
Cause --> Always
Value -.->|cancel propagates| Cancel
style Always fill:#dfd
style Value fill:#ffd
Every "why is my goroutine leaking" trace lands on a missing defer cancel() or a stored-in-struct context.[Go context]
Production gotchas the linter will not catch
Static analysis flags the obvious leaks. Three subtler patterns survive code review:
Type-assert with the comma-ok form, every time
The single-return lookup compiles, runs, and silently hands you the zero value when the key is absent or mistyped — the fix is comma-ok plus a loud fallback:
type ctxKey int
const (
requestIDKey ctxKey = iota
tenantIDKey
)
// Bad: the assertion silently drops to "" if the key is missing or
// the value is the wrong type. Downstream logs show empty strings.
func badLookup(ctx context.Context) string {
return ctx.Value(requestIDKey).(string)
}
// Good: explicit comma-ok branch and a sentinel so a missing value
// is loud, not silent. Production telemetry can alert on the sentinel.
func RequestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
if !ok || id == "" {
return "", false
}
return id, true
}
func handleOrder(ctx context.Context, orderID string) error {
rid, ok := RequestID(ctx)
if !ok {
// Loud failure rather than logs full of empty request IDs.
return fmt.Errorf("missing request id in ctx for order %s", orderID)
}
log.Printf("rid=%s order=%s", rid, orderID)
return nil
}Same rule for tenant IDs, identities, locales, feature flags — a helper hiding the bool will eventually mask a cross-service wiring bug.
Wire the signal handler into the cancel chain, not next to it
The classic shutdown bug: server, signal handler, and workers on three parallel context roots that never meet — SIGTERM returns from main while in-flight handlers die mid-write. Root everything in one signal.NotifyContext:
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: ":8080",
Handler: router(),
// BaseContext returns the parent for every incoming request,
// so SIGTERM cancels in-flight handler ctxs as well.
BaseContext: func(_ net.Listener) context.Context { return ctx },
}
go func() {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}
}signal.NotifyContext (Go 1.16+) is the correct entry point; the manual signal.Notify-channel-plus-cancelFunc pattern is the classic source of orphaned goroutines on shutdown.
Propagate ctx through OpenTelemetry, not around it
Trace propagation needs the same context that cancels the request. Two leaks: spans started from Background() (disconnected trace) and span pointers stored outside the context (writes silently no-op after cancel):
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func wireRouter(h http.Handler) http.Handler {
return otelhttp.NewHandler(h, "http.server")
}
func handleCheckout(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tracer := otel.Tracer("checkout")
ctx, span := tracer.Start(ctx, "checkout.validate")
defer span.End()
// Pass the derived ctx everywhere — never start a child span
// from Background() or you orphan the trace.
if err := validate(ctx, r); err != nil {
span.RecordError(err)
http.Error(w, "invalid", http.StatusBadRequest)
return
}
if err := charge(ctx, r); err != nil {
span.RecordError(err)
http.Error(w, "payment failed", http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
}For goroutines that outlive the request (async email, background export), build a fresh root from Background() and attach a span link with trace.WithLinks — trace topology preserved without binding the worker to the inbound deadline.
Choosing context.Context vs sync.WaitGroup
WaitGroup answers "is everyone done?"; context answers "should you stop?". Use context to broadcast cancel, WaitGroup to wait for clean exit — real services need both.
| Question | Reach for | Why |
|---|---|---|
| When should this goroutine give up? | context.Context | Carries deadline, cancel signal, and request values |
| How do I know all spawned goroutines drained? | sync.WaitGroup | Counts up on Add, blocks on Wait until all Done |
| One goroutine errors — do the rest abort? | errgroup.Group | Wraps WaitGroup with first-error short-circuit and shared ctx |
| Bound concurrency to N workers | errgroup.SetLimit or buffered channel | Context alone has no concurrency limit |
| Long-running fan-out with per-task deadline | Context + errgroup per task | Parent ctx for global cancel, child ctx per task budget |
errgroup.WithContext merges both into one API — the right default for new fan-out code.
import "golang.org/x/sync/errgroup"
func fetchAll(parent context.Context, urls []string) ([][]byte, error) {
g, ctx := errgroup.WithContext(parent)
g.SetLimit(8) // bounded concurrency
out := make([][]byte, len(urls))
for i, u := range urls {
i, u := i, u // pin for closure
g.Go(func() error {
// Inherit the group's ctx so the first error cancels siblings.
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", u, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
out[i] = body
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return out, nil
}The group ctx cancels on the first non-nil error; siblings see Done() close and abort cleanly. Hand-rolling this (WaitGroup + WithCancel + sync.Once) gives every line a place for a leak to hide.
Frequently Asked Questions
When should I call cancel() if I'm deriving a context in a loop?
Call cancel() immediately after each step finishes, before the next iteration. Do not defer once per loop — it holds all cancels until the loop exits.
for _, url := range urls {
ctx, cancel := context.WithTimeout(parent, 1*time.Second)
body, err := fetch(ctx, url)
cancel() // release immediately
if err != nil { return err }
}Is context.TODO() safe to ship?
No. go vet will not flag it, but it's a signal to reviewers that a real context was not threaded. Replace it before merging to main.
Why shouldn't I store context in a struct like Handler.ctx?
Because the context is tied to a single request's lifetime. If you cache it in a struct that lives longer (like a handler instance), it will be cancelled after the first request, and all subsequent requests will use a dead context.
What's the difference between DeadlineExceeded and Canceled?
DeadlineExceeded means the deadline passed (timeout). Canceled means someone called cancel() or the parent context was canceled. Map them to different HTTP status codes: 504 and 499, respectively.
Constructor reference table
A quick comparison of the five constructors and when to reach for each:
| Constructor | When to use | Cancellation source | Must defer cancel()? |
|---|---|---|---|
Background() | Top of main, server startup | Never | No |
TODO() | Placeholder during refactor | Never | No |
WithCancel(parent) | Caller might give up early | Explicit cancel() call | Yes |
WithTimeout(parent, d) | RPC budget, request SLA | Timer expiry or cancel() | Yes |
WithDeadline(parent, t) | Splitting a parent budget | Deadline reached or cancel() | Yes |
WithValue(parent, k, v) | Carrying request-scoped data | Inherits parent | No |
WithCancelCause(parent) | Need cause attribution (Go 1.20+) | cancel(err) with cause | Yes |
Keep Reading
Was this article helpful?
Your feedback directly shapes our editorial depth and technical accuracy.
Engineering Team
An independent engineering publication covering distributed systems, databases, and production infrastructure. Every factual claim is cited to a primary source or removed.
Read Next
Go Context in Depth: Cancellation, Timeouts, and Debugging in Production
Master context.Context in Go: cancellation propagation, deadline inheritance, goroutine leak patterns, and debugging with pprof.
Go Worker Pool Pattern: Production-Ready Concurrency Control
Build a production-ready Go worker pool with goroutines and channels. Control concurrency and prevent resource exhaustion.
Go 1.26 Green Tea GC in Production: What Changes, How to Measure It, When to Opt Out
Go 1.26 makes the Green Tea garbage collector the default. What actually changed, how to measure GC CPU before and after on your own workload, and the short window you have to opt out.