open atlas
↑ Back to track
Go, zero to senior GO · 07 · 01

Context discipline: cancellation, deadlines, and values that flow down the call chain

Context carries cancellation, deadlines and request-scoped values down every call chain: ctx first arg, derive with WithTimeout and defer cancel or you leak timers. Cancellation is cooperative — code must check ctx.Done(). Values carry metadata, never dependencies.

GO Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Black Friday load test, checkout p99 blowing through its SLO. The payment provider was slow, so the team raised the payment client timeout from five seconds to ten, redeployed — and watched every call keep dying at exactly five. Three engineers, two hours of disbelief later, someone traced the request from the edge: the API gateway wrapped every inbound request in context.WithTimeout(ctx, 5*time.Second), and the payment client derived its ten-second context from that parent. A derived context can shrink the remaining deadline, never extend it — the child asked for ten and inherited five. The one-line fix was boring. The expensive lesson was the audit that followed: four layers each set their own timeout, nobody could say which one a given request actually died under, and go vet found eleven WithTimeout calls whose cancel was discarded — each one quietly pinning a timer and a context in memory until the timer fired. One tree per request, and you had better know its shape.

By the end of this lesson you will know exactly why that ten-second timeout silently did nothing, where timers leak, and which three rules make context bugs nearly impossible to write.

One tree per request

A context.Context carries three things down a call chain: a cancellation signal, a deadline, and a small bag of request-scoped values. The convention is rigid on purpose — ctx is the first parameter of every function that does I/O or can block, so the conduit is never broken mid-chain:

func (s *Server) handleCheckout(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // already cancels when the client disconnects

	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel() // releases the timer and detaches the child from the parent

	order, err := s.orders.Load(ctx, orderID) // ctx first, everywhere
	// ...
}

WithCancel, WithTimeout and WithDeadline do not mutate a context — they derive a child and register it in the parent’s children set. Cancel a parent and every descendant cancels with it; a child can never cancel or outlive its parent. That tree shape is what makes the Hook incident inevitable: WithTimeout(parent, 10*time.Second) when the parent has five seconds left yields an effective deadline of five — the minimum along the path from the root always wins. Setting a generous timeout deep in the stack does nothing if an ancestor was stingier.

Forgetting cancel is the quiet leak. WithTimeout allocates a time.Timer and links the child into the parent; until you call cancel, both stay reachable — for a ten-minute timeout on a job that finishes in 200 ms, that is ten minutes of dead weight per job, multiplied by your request rate. Derive from a long-lived parent with WithCancel and forget the cancel, and the leak is permanent. defer cancel() on the next line is the idiom, and go vet (the lostcancel check) flags the violation.

Quiz

A gateway wraps requests in a 5s context. Deep in the stack, the payment client does context.WithTimeout(ctx, 10*time.Second) and the provider replies in 7s. What happens?

Derive, do not store

The anti-pattern that breaks cancellation scoping is storing a context in a struct field. A context represents one call’s lifetime; a struct usually outlives many calls. Freeze a request’s context into an object and every later method call inherits a dead or foreign cancellation scope — work for request B gets cancelled when request A’s client disconnects, or never gets cancelled at all. The rule from the Go team is blunt: pass ctx as a parameter to each method that needs it; do not store it. The one sanctioned exception proves the rule — http.Request holds a context because a Request is a single call, created and discarded with it.

Cancellation is cooperative

When you call cancel(), nothing is killed — and that surprises most engineers the first time they trace an orphaned query. cancel() kills nothing. It closes the context’s Done channel and makes ctx.Err() return context.Canceled (or context.DeadlineExceeded when a timer fired). Everything else is your code’s job: blocking operations must take ctx and honor it — database/sql, net/http, net.Dialer all do — and CPU-bound loops must check ctx.Err() between iterations, because nothing will interrupt them. The classic production smell is the orphaned query: the handler returns 504, the client is long gone, and the SQL statement keeps grinding on the database because someone called the one method that does not accept a context. Cancellation discipline is only as strong as the least cooperative frame in the stack.

Why this works

Why cooperative instead of preemptive? Killing a goroutine at an arbitrary instruction would strand held mutexes, half-written buffers and broken invariants — the exact chaos that made thread cancellation infamous in other runtimes. Go chose the honest contract: cancellation is a broadcast (a closed channel every descendant can select on), and each function decides where it is safe to stop. The price is discipline — code that never checks ctx.Done() is code that cannot be cancelled, and no framework can fix that from the outside.

Values: metadata, not dependencies

Before you reach for context.Value, ask: is this request metadata that middleware needs to cross an API boundary — or is it a dependency that belongs in a constructor? The answer determines whether you write resilient code or an invisible footgun. context.Value answers one narrow question: what request-scoped facts must cross API boundaries that were never designed to carry them? Trace id, request id, authenticated principal — data that describes this request and that middleware attaches for layers it cannot see. The typed-key pattern keeps it collision-free:

type principalKey struct{} // unexported: no other package can collide

func WithPrincipal(ctx context.Context, p Principal) context.Context {
	return context.WithValue(ctx, principalKey{}, p)
}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey{}).(Principal)
	return p, ok
}

What does not belong in there: dependencies. A database handle, a config struct, an API client in context.Value is an invisible, untyped, compile-time-unchecked parameter — the function signature claims independence while the body crashes on a nil assertion when some caller forgot the magic incantation. Dependencies go in struct fields wired at construction; the lookup is also a linked-list walk up the tree, fine for three values, wrong as a general store.

Detaching: work that must survive the request

Sometimes cancelling the request must not cancel the work: the audit record of a payment attempt has to be written even when the client disconnects mid-flight — especially then. context.WithoutCancel(ctx) (Go 1.21) derives a context that keeps the parent’s values — your trace id still flows into the audit record — but ignores its cancellation and deadline. Give the detached work its own bound so it cannot run forever:

// The audit write survives the request but not the process: own timeout, parent's values.
auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
audit.Record(auditCtx, event)
Quiz

An audit record must be written even if the client disconnects, and it must carry the request trace id. Which context do you hand the audit writer?

Recall before you leave
  1. 01
    Walk through why a child WithTimeout(ctx, 10s) under a 5s parent fails at 5s, and what forgetting cancel() costs.
  2. 02
    State the rule for context.Value and for storing contexts in structs, with the failure mode each rule prevents.
Recap

Every request owns one context tree. The root arrives at the edge — r.Context() already cancels on client disconnect — and each layer derives children with WithCancel, WithTimeout or WithDeadline, passing ctx as the first argument so the conduit never breaks. Derivation only shrinks: a child asking for ten seconds under a five-second parent gets five, which is why timeouts raised deep in a stack silently do nothing. Every derivation returns a cancel function that must run — defer cancel() — or the timer and child context stay pinned to the parent; vet’s lostcancel check exists because production heaps kept filling with dead timerCtx values. Cancellation is cooperative: cancel() closes Done and sets Err, and code must select on Done or check Err between units of work — a layer that ignores ctx grinds on for clients that left, the orphaned-query pattern every database team eventually graphs. Contexts are parameters, never struct fields: an object outlives calls, and a frozen context drags one request’s lifetime into another’s. Value is for request metadata behind typed unexported keys — trace id, principal — never for dependencies, which belong in constructors where the compiler can see them. And when work must survive the request — audit records, billing events — context.WithoutCancel keeps the metadata, drops the cancellation, and deserves its own deadline so detached never becomes immortal. Now when you see a request dying earlier than its timeout suggests, check the parent first — the effective deadline is always the minimum along the path to the root.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.