Two failure modes, opposite in effect, same root cause. Both compile, and both are quiet in development.
Failure one: the work dies with the request
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
order := parse(r)
save(order)
go func() {
h.sendConfirmationEmail(r.Context(), order) // cancelled the moment Create returns
}()
w.WriteHeader(http.StatusCreated)
}
r.Context() is cancelled when the handler returns and the response is written. Your goroutine gets a context that is already dead, or dies milliseconds later. Emails silently don't send — and under local testing, where the goroutine usually wins the race, everything looks fine.
Since Go 1.21 the fix is one call:
ctx := context.WithoutCancel(r.Context())
go h.sendConfirmationEmail(ctx, order)
WithoutCancel keeps the values — trace IDs, request-scoped logger, auth subject — while detaching from the parent's cancellation. Before 1.21 you had to hand-roll a context.Background() and re-attach every value you cared about, which is how those values usually got lost.
Give the detached work its own deadline, because it no longer has one:
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second)
go func() {
defer cancel()
h.sendConfirmationEmail(ctx, order)
}()
Failure two: the cancellation never arrives
func fetchAll(ctx context.Context, ids []string) ([]Item, error) {
var items []Item
for _, id := range ids {
item, err := db.Get(context.Background(), id) // ignores ctx entirely
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
The caller cancels. Nothing stops. The loop runs to completion against a client that hung up, holding a database connection the whole time. Under load this is how a spike in cancelled requests turns into connection pool exhaustion.
The signature accepts a ctx and then ignores it, which is worse than not accepting one — every caller reasonably assumes cancellation works.
Making it visible
go vet catches a lost cancel, but not a lost ctx. Two things that do help:
contextcheck (available via golangci-lint) flags functions that receive a context and then pass context.Background() downstream — exactly failure two.
# .golangci.yml
linters:
enable:
- contextcheck
- noctx # flags http.NewRequest without a context
A test that asserts cancellation propagates. If it matters, assert it:
func TestFetchAllRespectsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := fetchAll(ctx, []string{"a", "b", "c"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
Pre-cancelling before the call is the cheap version and catches most regressions.
The convention that prevents both
A context is scoped to an operation. When the operation's lifetime changes, the context has to change with it — explicitly.
Concretely:
- Work that must outlive the request:
context.WithoutCancel, plus a fresh timeout - Work that is part of the request: pass
ctxdown, unchanged, to every call that accepts one -
context.Background()appears inmain, in tests, and essentially nowhere else
The third rule is the one worth grepping for. Every context.Background() inside a request path is either failure two, or a comment waiting to be written explaining why it isn't.
These posts come out of material I build for my Udemy courses — 25 of them now, mostly drill-based, across Go, Python, TypeScript, testing and Three.js. If this was useful, the full list is at udemy-c1f90.web.app. The links on that page carry a coupon I refresh each month, which usually lands around half the list price.
Top comments (0)