One Hanging Dependency Should Not Sink the Request
A checkout handler fans out to inventory, pricing, and fraud checks. Inventory answers in 40ms, pricing in 80ms — fraud hangs for 30 seconds because its connection pool is exhausted. Without a bound, your handler hangs for 30 seconds, the client retries (creating another hung handler), and the retry storm fills the pool further. The downstream failure becomes your failure, amplified.
The bound belongs to your operation, so you attach one:
func (s *Service) Checkout(ctx context.Context, cart Cart) (Receipt, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := s.checkFraud(ctx, cart); err != nil {
return Receipt{}, fmt.Errorf("checkout: %w", err)
}
// ... rest of the flow inherits the same 2s budget
return s.commit(ctx, cart)
}
func (s *Service) checkFraud(ctx context.Context, cart Cart) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fraudURL, body)
if err != nil {
return err
}
resp, err := s.client.Do(req) // returns promptly when the deadline lands
if err != nil {
return err // wraps context.DeadlineExceeded
}
defer resp.Body.Close()
// ...
return nil
}
WithTimeout(ctx, d) returns a child whose deadline is now + d. When that moment arrives, the child's Done channel closes exactly as if cancel() had been called — timeouts are cancellation, scheduled in advance — and ctx.Err() reports context.DeadlineExceeded instead of context.Canceled. The check at the boundary is an identity check like any other:
if errors.Is(err, context.DeadlineExceeded) {
return ErrUpstreamTooSlow // distinct from ErrUpstreamDown
}
That distinction earns its keep in operations: a timeout tells you the dependency is slow (queue building, pool starved) while a refused connection tells you it is down. Reporting both as err.Error() contains "deadline" string-matching is exactly the fragility error handling warned about.
Three mechanics worth pinning down. The clock starts at WithTimeout, not at the first use — constructing the context early in the handler and doing 500ms of setup before the call spends the budget you meant for the call. The goroutine parking is in the driver: your select or the HTTP client watches Done and wakes; cancel() itself is non-blocking. The deferred cancel is mandatory here too — even though the timer fires eventually, a completed-with-1.8s-left request that skips cancel holds the timer until it expires (in older runtimes) or until the parent dies. defer cancel() releases it the instant the function returns, which is the difference between timers proportional to in-flight requests and timers proportional to all requests ever.
Deadlines Compose, So Budget Them
Contexts inherit deadlines through the tree, and the inheritance rule is one sentence: a child's effective deadline is the earlier of its own deadline and its parent's. A child created with WithTimeout(parent, 5s) when the parent expires in 2s dies at 2s — the parent's budget caps every descendant no matter what the child asked for.
That rule turns timeout design into budget arithmetic:
// request has 2s total; three sequential stages must fit inside it
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
t0 := time.Now()
if err := s.reserve(ctx); err != nil { // stage budget: 900ms
return err
}
stageCtx, cancelStage := context.WithTimeout(ctx, 900*time.Millisecond)
defer cancelStage()
if err := s.charge(stageCtx); err != nil {
return err // if parent had 400ms left, THIS fires at 400ms — the earlier deadline wins
}
Two patterns fall out of the rule. Ask for the stage budget, not the remainder — each stage states what it needs; the parent's remaining time clamps it automatically, so no stage can accidentally plan beyond the request's end. Read the parent's remaining time when you need the actual number, which is exactly what ctx.Deadline() exists for:
if dl, ok := ctx.Deadline(); ok {
remaining := time.Until(dl)
log.Printf("budget for this stage: %s", remaining)
}
The ok bool matters: contexts without deadlines (plain Background(), a cancel-only context) return false, and code that assumes a deadline always exists crashes on exactly those paths — servers under test, CLI tools, internal jobs.
Sizing the numbers themselves separates production code from toy examples. One blanket 30-second timeout for a request that calls six services is not a policy; it is an absence of one. Derive values from evidence: the dependency's p99 plus headroom for the stage (2×p99 is a common starting point), never below a floor where cold-start noise causes false failures, and never above your own caller's patience — a stage with a 10s budget under a 2s request budget is dead weight that only delays failure detection. When a dependency needs different limits for different operations (search: 300ms; report export: 30s), set them per call site with WithTimeout at the stage boundary, not on the shared client — a client-level timeout would either cap the export or cushion the search wrongly.
One trap in loops: allocating a fresh timeout per iteration without stopping it accumulates pending timers. The WithTimeout + defer cancel() form inside a function is fine (the defer runs each call), but hoisting context.WithTimeout inside a hot loop without calling cancel immediately is not — and time.After-style allocation in a select loop, from select, is the same species of mistake. Always pair creation with immediate, scoped cancellation.
context.Value: The Last Resort, Used Correctly
Every context carries an optional invisible payload — request IDs, trace spans, authenticated identity — and no other mechanism in the standard library passes request-scoped data down an arbitrary call depth. This is ctx.Value, and it is the most abused member of the package: half the industry uses it for dependency injection it was not meant for.
The legitimate shape first:
type ctxKey string
const requestIDKey ctxKey = "request-id"
// top of the handler (framework or middleware):
ctx = context.WithValue(r.Context(), requestIDKey, newID())
// deep in a logging helper, no parameter threading:
func logOp(ctx context.Context, msg string) {
logger.Info(msg, "request_id", ctx.Value(requestIDKey))
}
Three rules make this safe, and each one has a failure it prevents:
- Unexported, typed keys (
type ctxKey string). Package-level string keys collide across packages — two middlewares both using"user"overwrite each other silently. A type private to your package makes outside code unable to even spell your key, eliminating the collision class entirely. - Values are for request-scoped metadata, not configuration. Database URLs, feature flags, timeouts, and business inputs have clear owners and belong in parameters, structs, or config files — visible in signatures, reviewable, settable at startup.
ctx.Valuehides data flow: a reviewer cannot see from the signature what the function depends on, and tests must reconstruct hidden inputs. - The
okidiom on read.v, ok := ctx.Value(key).(string)— a missing value yieldsnil, and a bare type assertion on nil panics. Metadata is optional by nature (a CLI test may have no request ID); handle absence explicitly.
The tradeoff is worth stating plainly so the decision is conscious: adding a parameter is visible, typed, and testable — it costs a changed signature at every layer. Value costs none of that and pays with invisibility — the same call sites that make tracing headers convenient make ctx.Value("sql_pool") a landmine no one can grep for honestly. The community line — values should be for metadata like request ID, tracing, auth — data that flows down the request, not data the function needs to work — is really an API-design principle: use context when the alternative is touching forty signatures for something optional and ambient; use explicit parameters when the dependency is required and central.
The synthesis across both halves of this topic: context's cancellation and deadline machinery enforces when work must stop; its values carry who is asking for correlation across the same span. Timeouts answer for the process's safety — no operation outlives its budget — and the disciplined value answers for observability — every log line from that operation can be tied back to one request. Both misuse the type the same way: turning a per-operation carrier into a general-purpose blackboard. Keep it per-operation, keep keys typed, and let the signature say everything the function actually requires.
Quiz
1. A parent context has 400ms left. Code calls `context.WithTimeout(parent, 5*time.Second)` for a child. When does the child expire?
2. An HTTP call returns an error after the context's timeout fired. How do you distinguish this from a connection refusal?
3. Why do middleware prefer ctx.Value for a request ID over adding a parameter to every function?
4. A loop runs 10,000 iterations, each creating `context.WithTimeout(bg, 50*time.Millisecond)` without calling cancel. What accumulates?
Flashcards
Question
How does a context deadline relate to cancellation, and what does Err() return in each case?
Click to reveal answer
Answer
A deadline is pre-scheduled cancellation: when it arrives, Done closes exactly as if cancel() had been called. The difference is Err() — context.Canceled for an explicit cancel, context.DeadlineExceeded for an expired deadline — which callers branch on with errors.Is to separate abandoned work from work that ran out of time.
Question
State the deadline inheritance rule and one design consequence for multi-stage requests.
Click to reveal answer
Answer
A child's effective deadline is the earlier of its own deadline and its parent's — the parent caps the whole tree. Consequence: each stage requests its own budget with WithTimeout under the request's context, and the parent's remaining time clamps it automatically; no stage needs to compute 'remaining time' or can accidentally overshoot the request's end.
Question
How do you size a per-stage timeout instead of using one blanket request timeout?
Click to reveal answer
Answer
Measure the dependency's p99 for that operation and add headroom (roughly 2×p99 as a start), keep a floor above cold-start noise, and never exceed your own caller's patience — a 10s stage budget under a 2s request budget only delays failure. Set limits per call site when operations differ (search vs export); client-wide timeouts get the policy wrong for one of them.
Question
Why must context value keys be unexported, typed constants instead of plain strings?
Click to reveal answer
Answer
String keys are shared namespace: two packages both storing under "user" silently overwrite each other, and the collision surfaces as mysteriously wrong data. A package-private key type (type ctxKey string) makes the key unspellable outside its package, so no external code can collide with or accidentally read your metadata.
Question
What belongs in context.Value and what belongs in function parameters?
Click to reveal answer
Answer
Values carry optional, request-scoped, ambient metadata — request IDs, trace spans, authenticated identity — that flows down a call tree where threading parameters would touch dozens of signatures for data most callees ignore. Required dependencies (DB handles, config, business inputs) stay in parameters: visible in the signature, reviewable, and constructible in tests without reconstructing hidden state.
Revision Notes
Key Takeaways
- 1. Timeouts are scheduled cancellations: Done closes on expiry and Err() reports DeadlineExceeded, distinguishable with errors.Is
- 2. Child deadlines clamp to the parent's earlier expiry — per-stage budgets compose without arithmetic on remaining time
- 3. Sizing comes from measured p99 plus headroom per call site; one blanket request timeout is a missing policy
- 4. Values are typed-key request metadata only — required dependencies stay in parameters where signatures and tests can see them
- 5. Creation and cancellation are inseparable: defer cancel() everywhere, or timers and registrations scale with call count
Interview Tips
- • "Add a timeout to this call" — WithTimeout with defer cancel, the Context-suffixed API, then classify with errors.Is(DeadlineExceeded) and explain why the distinction from Canceled matters operationally
- • "Your service calls three dependencies — how do you set timeouts?" — budget arithmetic: request budget at the top, per-stage budgets from p99 measurement, explain that min-deadline inheritance enforces the cap automatically
- • "When would you use context.Value?" — give the request-ID example with a typed unexported key and the ok-idiom read; then proactively state what does NOT belong there (config, business inputs) and why
- • "How do you debug a request that timed out?" — check which deadline fired via errors.Is, use ctx.Deadline() logs to see remaining budgets per stage, and separate a slow dependency from a dead one by error identity
Cheat Sheet
Timeouts, Deadlines, and Values
Timeout pattern:
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // releases timer immediately on return
req, _ := http.NewRequestWithContext(ctx, method, url, body)
resp, err := client.Do(req)
if errors.Is(err, context.DeadlineExceeded) {
// slow dependency — distinct from down
}
Inheritance: effective deadline = min(child's own, parent's). Ask for the stage budget; the parent clamps. Read actual time with dl, ok := ctx.Deadline() — handle ok == false.
Sizing: per-stage, from measured p99 × headroom; floor above noise; never exceed caller patience; per call site when operations differ.
Value rules:
type ctxKey string
const ridKey ctxKey = "request-id"
ctx = context.WithValue(parent, ridKey, id) // store
v, ok := ctx.Value(ridKey).(string) // read, always with ok
| Put in ctx | Put in parameters |
|---|---|
| request ID, trace, auth identity | DB handles, config, timeouts, business inputs |
| optional, ambient, request-scoped | required, central, test-critical |
Allocation rule: every WithTimeout/WithDeadline is paired with an immediate cancel — defer inside functions, direct call in loops.