Context & Cancellation
The context tree, timeouts, deadlines, values, cancellation propagation
- 01
A reviewer asks why one function starts its chain with
context.TODO()and the one next to it usescontext.Background(). What is the runtime difference?EasyThere is none — both return an empty context that is never cancelled, has no deadline and holds no values;
TODOis a marker for humans and linters that a real context should be threaded here later.// Both roots are the same empty context; only String() differs. func probe() { fmt.Println(context.Background(), context.TODO()) // context.Background context.TODO fmt.Println(context.Background().Done() == nil) // true: a nil channel, blocks forever fmt.Println(context.Background().Err()) // <nil>, and it stays nil } … - 02
In a worker loop you
selectonctx.Done()and then logctx.Err(). A colleague calls the log redundant because the channel already fired. What doesErr()tell you that the channel cannot?EasyDone()says that the context ended,Err()says why —context.Canceledwhen someone calledcancel,context.DeadlineExceededwhen a deadline or timeout fired, and nil while the context is still live.func worker(ctx context.Context, jobs <-chan Job) error { for { select { case <-ctx.Done(): // Done() says it ended; Err() is already set and says why. return ctx.Err() // context.Canceled or context.DeadlineExceeded … - 03
go vetfails the build with the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak, on code that has shipped for a year. The timeout is one second — what is there to leak?EasyThe child context stays registered in its parent until it is cancelled —
cancel()is the only thing that unlinks it, and forWithTimeoutit also stops thetime.Timer, so discarding it keeps both alive for the full duration on every single call.// ❌ vet: the cancel function returned by context.WithTimeout should be called, // not discarded, to avoid a context leak func fetchBad(parent context.Context, id string) (*User, error) { ctx, _ := context.WithTimeout(parent, time.Second) return get(ctx, id) } … - 04
A review rejects
type Worker struct { ctx context.Context }even though every test passes. Beyond style, what actually breaks in production?EasyA context is per-call state, not per-object state — storing one pins a single lifetime onto every future call, so the second caller silently inherits the first caller's deadline, values and cancellation.
// ❌ one stored context decides the lifetime of every future call type BadWorker struct { ctx context.Context // request #1's deadline, values and cancellation, forever db *sql.DB } … - 05
A handler wraps its context in a 100 ms timeout for a cache lookup and then passes the same variable to the primary database call. The cache is fast and healthy, yet the database call dies after 100 ms. Why?
MediumDerivation builds a tree and cancellation only flows downward — the database call was made a child of the cache's short-lived context, so it inherited a deadline that had nothing to do with it.
// ❌ the timeout meant for the cache swallows everything written after it func (h *Handler) getBad(ctx context.Context, id string) (*Page, error) { ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) // shadows ctx defer cancel() if p, err := h.cache.Get(ctx, id); err == nil { … - 06
Your repository layer wraps every query in
context.WithTimeout(ctx, 5*time.Second)so slow SQL cannot hang a request. In production the queries abort after 300 ms instead. Where does 300 ms come from?MediumA derived deadline can only shrink, never grow —
WithTimeoutkeeps whichever deadline is earlier, and the incoming request's budget was 300 ms.func (r *Repo) Load(ctx context.Context, id int64) (*Row, error) { // A ceiling on what is LEFT, not a grant of five seconds. ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 20*time.Millisecond { … - 07
A retry helper turns a 300 ms p99 into a 3 s one whenever users close a tab, and all the retries fail instantly without touching the network. What is it retrying?
Mediumcontext.Canceled— it is retrying against a context that is already dead, so every attempt returns before it reaches the wire: the first thing a context-aware call does is checkctx.Err().// ❌ retries a context that is already dead, and sleeps after the caller is gone func retryBad(ctx context.Context, f func(context.Context) error) error { var err error for i := 0; i < 3; i++ { if err = f(ctx); err == nil { return nil … - 08
After a vendored middleware is added,
ctx.Value("user_id")in your handler starts returning another tenant's id. Both packages compile and neither is buggy on its own. What collided?MediumTwo packages used the same
stringkey in one context, and a lookup walks up the chain returning the nearest match — whichever value was attached last wins.// ❌ a string key is public API you never meant to publish func badMW(ctx context.Context, u *User) context.Context { return context.WithValue(ctx, "user_id", u.ID) // any package can shadow this } // ✅ an unexported key type that no other package can construct … - 09
go audit.Write(r.Context(), event)at the end of a handler writes about 60% of its rows, and the misses correlate with fast clients. What is racing what?MediumA server request context is cancelled the moment
ServeHTTPreturns, so the detached goroutine is writing through a context that dies microseconds after it starts.func (a *App) handler(w http.ResponseWriter, r *http.Request) { // ❌ r.Context() is canceled the instant ServeHTTP returns go audit.Write(r.Context(), event(r)) // ✅ keep the values (trace id, tenant), drop the cancellation, add a bound bg := context.WithoutCancel(r.Context()) // Go 1.21 — the deadline goes too … - 10
Every statement in a batch job gets its own
context.WithTimeout(ctx, 2*time.Second), butBeginTxwas handedcontext.Background(). The job is cancelled at its deadline, and next morning the table is still held by anidle in transactionsession. Which context was the wrong one?MediumThe context given to
BeginTxowns the transaction — statement contexts abort only their own statement, so a transaction rooted atcontext.Background()is never rolled back by cancellation and keeps its connection, its locks and its snapshot until something explicitly ends it.// ❌ the transaction outlives the job: cancelling ctx rolls back nothing func importBad(ctx context.Context, db *sql.DB, rows []Row) error { tx, err := db.BeginTx(context.Background(), nil) // wrong context if err != nil { return err } … - 11
A proxy endpoint streams an upstream response to the client. The client disconnects after a few megabytes, the handler returns at once, and the upstream keeps sending for another ten minutes. The outbound request was built with
http.NewRequest. What is keeping the upstream transfer alive?MediumThe outbound request was never given a context —
http.NewRequestroots it atcontext.Background(), so the disconnect cancelsr.Context()and reaches nothing else.// ❌ the upstream request runs on context.Background(): the client leaving // cancels r.Context() and nothing else func proxyBad(w http.ResponseWriter, r *http.Request) { req, _ := http.NewRequest(http.MethodGet, upstream+r.URL.Path, nil) resp, err := client.Do(req) if err != nil { … - 12
A report job derives a 30-second timeout, and the endpoint still returns after four minutes with
ctx.Err()non-nil for most of that. Nothing inside the job blocks on I/O. Why does the deadline not stop it?MediumCancellation is cooperative — cancelling closes a channel and sets an error; it does not interrupt a goroutine that is busy computing, so a pure-CPU loop runs to the end no matter what the context says.
// ❌ nothing interrupts a busy goroutine: this runs to the end whatever ctx says func renderBad(ctx context.Context, rows []Row) (*Report, error) { var rep Report for _, r := range rows { rep.Add(score(r)) // pure CPU, no context in sight } … - 13
Every failure in a fan-out reports
context canceled, so the logs never say which shard actually broke. How do you carry the reason down the tree without inventing a side channel?Hardcontext.WithCancelCausetogether withcontext.Cause(ctx)— the cause travels down the tree with the cancellation, whilectx.Err()keeps returning the plain sentinel everyerrors.Ischeck depends on.var ErrSearchBudget = errors.New("search budget exhausted") func search(parent context.Context, q string) ([]Hit, error) { // Err() stays context.DeadlineExceeded; Cause() names the layer that ran out. ctx, cancel := context.WithTimeoutCause(parent, 200*time.Millisecond, ErrSearchBudget) defer cancel() … - 14
You have to abort a third-party client that exposes
Close()but takes nocontext.Context. A goroutine that waits onctx.Done()and callsClose()leaks one goroutine per call. What does the standard library offer instead?Hardcontext.AfterFunc(ctx, f), added in Go 1.21 — it registers a callback on the context's cancellation and returns astopfunction, so nothing is parked per call and nothing leaks when the call finishes first.// ❌ one parked goroutine per call, and it outlives every call that succeeds func callBad(ctx context.Context, c *vendor.Client) error { go func() { <-ctx.Done() // never returns if ctx is long-lived and the call finishes c.Close() }() … - 15
srv.Shutdown(ctx)returnscontext.DeadlineExceededon every deploy, and the handlers it was waiting for keep running afterwards — a shorter context only makes it return sooner. How do you actually tell in-flight requests to stop?HardShutdownwaits, it never cancels — the context you hand it bounds your own waiting and nothing else.func main() { sigCtx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stopSignals() // Every r.Context() descends from this one. … - 16
Service A gives B 500 ms; B calls C through a client with a fixed 2 s timeout. A's 504 rate looks fine, but C's CPU is pinned by work nobody is waiting for any more. What is missing between the hops?
HardThe deadline never made it onto the wire — a context is an in-process object, so unless B serialises the time it has left, C starts a fresh, generous budget for work that A abandoned a second and a half ago.
const hopHeadroom = 50 * time.Millisecond // Caller: put the REMAINING time on the wire, never an absolute timestamp. func forward(ctx context.Context, req *http.Request) (*http.Response, error) { if dl, ok := ctx.Deadline(); ok { budget := time.Until(dl) - hopHeadroom …