Go Context Cancellation Patterns
Master Go's context package: propagate deadlines, cancel goroutines safely, and avoid leaks with practical patterns for HTTP, database, and pipeline code.
What you'll learn
- ✓How context propagates cancellation across goroutines
- ✓Choosing WithCancel, WithTimeout, and WithDeadline
- ✓Wiring context through HTTP handlers and DB calls
- ✓Cleaning up goroutines with select and Done
- ✓Common context anti-patterns to avoid
Prerequisites
- •Familiar with shell
- •Comfortable with goroutines and channels
What and Why
A long-running Go program almost always has goroutines that should stop when the request that started them goes away: the user closed the browser, the deadline passed, or an upstream RPC failed. context.Context is Go’s standard channel-shaped signal for “stop now,” carrying deadlines and cancellation across API boundaries.
Without context, you write ad hoc done-channels, leak goroutines, and burn CPU on work nobody is waiting for. With it, you get a uniform shutdown contract from net/http down to your database driver.
Mental Model
A context is an immutable value with four read-only methods: Deadline, Done, Err, and Value. Done() returns a channel that closes when the context is cancelled. Functions derive child contexts from a parent; cancelling a parent cancels every descendant, but not the other way around.
Think of it as a tree of cancellation. The root is usually context.Background() (for main) or r.Context() (for an HTTP handler). Each operation that needs its own deadline or cancellation creates a child, then must call the returned cancel function to release resources.
Hands-on Example
A handler that fans out two upstream calls and returns whichever finishes first, with a hard 2-second budget for the whole operation.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func fetch(ctx context.Context, url string) (string, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
return resp.Status, nil
}
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
type result struct {
from string
body string
err error
}
out := make(chan result, 2)
for _, u := range []string{"https://a.example", "https://b.example"} {
u := u
go func() {
body, err := fetch(ctx, u)
out <- result{from: u, body: body, err: err}
}()
}
select {
case res := <-out:
if res.err != nil {
http.Error(w, res.err.Error(), http.StatusBadGateway)
return
}
fmt.Fprintf(w, "winner=%s status=%s\n", res.from, res.body)
case <-ctx.Done():
http.Error(w, ctx.Err().Error(), http.StatusGatewayTimeout)
}
}
When the timeout fires, every in-flight http.Client.Do aborts because the request was created with NewRequestWithContext. The losing goroutine wakes up, sends its (possibly error) result into the buffered channel, and exits. No leaks.
request ctx (r.Context())
|
+-- WithTimeout(2s) --> ctx (handler)
|
+-- goroutine: fetch A (uses ctx)
+-- goroutine: fetch B (uses ctx)
timeout or cancel():
ctx.Done() closes
|
v
in-flight HTTP requests abort
goroutines observe Done() and exit For a worker pool, the pattern is the same but driven by context.WithCancel:
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
process(ctx, j)
}
}
}
Common Pitfalls
- Forgetting
defer cancel().WithCancel,WithTimeout, andWithDeadlineall return acancelfunc. If you don’t call it, the parent keeps a reference to the child until the deadline fires, leaking memory and timers. - Passing
context.TODO()in production.TODOis for code you haven’t refactored yet. In a real path, plumb a real context from the caller. - Storing contexts in structs. The standard library convention is to pass
ctxas the first function argument. Stashing it on a struct hides the dependency and makes cancellation hard to reason about. - Using
Valueas a grab bag.context.Valueis for request-scoped data like trace IDs, not application config or dependencies. - Ignoring
ctx.Err(). A goroutine that selects onctx.Done()should usually returnctx.Err()so callers can distinguish cancellation from timeout.
Practical Tips
- Always make
ctx context.Contextthe first parameter, and never accept anilcontext. Usecontext.Background()at the top ofmainor tests. - Set the deadline at the boundary where it makes sense (the HTTP handler, the RPC entry point), not deep inside helpers. Helpers inherit and shorten if needed.
- For per-attempt timeouts inside a retry loop, derive a fresh child with
WithTimeouteach iteration so the inner deadline resets while the outer budget keeps shrinking. - Pair
errgroup.WithContextwith cancellation to coordinate multiple goroutines that should all stop when any one fails. - In tests, use
context.WithDeadline(t.Context(), ...)(Go 1.24+) or passt.Cleanup(cancel)to guarantee cancellation when the test exits.
Wrap-up
context.Context is the contract that lets goroutines, HTTP requests, and database calls cooperate on shutdown. Derive children for new deadlines, always call cancel, propagate ctx as the first argument, and select on Done() in any blocking loop. With those habits, your services drain cleanly, your goroutines stop leaking, and your upstream errors propagate the way the standard library expects.
Related articles
- Go Go context Package Explained
How to use Go's context package effectively: cancellation, deadlines, propagation, request-scoped values, and the patterns that keep services responsive.
- Go Go Goroutines and Channels Tutorial
A practical introduction to concurrency in Go: goroutines, channels, select, common patterns like fan-out/fan-in, and the pitfalls that cause leaks and races.
- Go Go defer, panic, and recover Tutorial
Learn how Go's defer, panic, and recover work together, when to use each, and how to write resilient code without abusing exceptions.
- Go Go Struct Tags and Reflection
Understand how Go struct tags work, how packages like encoding/json read them with reflection, and how to add custom tag-driven behavior to your code.