Context & Cancellation
Irbisa · cheatsheetSeptember 19, 2026

Context & Cancellation

The context tree, timeouts, deadlines, values, cancellation propagation

Middle Developer16 itemscompressed for a skim
  1. 01

    A reviewer asks why one function starts its chain with context.TODO() and the one next to it uses context.Background(). What is the runtime difference?

    Easy

    There is none — both return an empty context that is never cancelled, has no deadline and holds no values; TODO is 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
    }
    …
  2. 02

    In a worker loop you select on ctx.Done() and then log ctx.Err(). A colleague calls the log redundant because the channel already fired. What does Err() tell you that the channel cannot?

    Easy

    Done() says that the context ended, Err() says whycontext.Canceled when someone called cancel, context.DeadlineExceeded when 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
    …
  3. 03

    go vet fails 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?

    Easy

    The child context stays registered in its parent until it is cancelledcancel() is the only thing that unlinks it, and for WithTimeout it also stops the time.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)
    }
    …
  4. 04

    A review rejects type Worker struct { ctx context.Context } even though every test passes. Beyond style, what actually breaks in production?

    Easy

    A 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
    }
    …
  5. 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?

    Medium

    Derivation 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 {
    …
  6. 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?

    Medium

    A derived deadline can only shrink, never growWithTimeout keeps 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 {
    …
  7. 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?

    Medium

    context.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 check ctx.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
    …
  8. 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?

    Medium

    Two packages used the same string key 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
    …
  9. 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?

    Medium

    A server request context is cancelled the moment ServeHTTP returns, 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. 10

    Every statement in a batch job gets its own context.WithTimeout(ctx, 2*time.Second), but BeginTx was handed context.Background(). The job is cancelled at its deadline, and next morning the table is still held by an idle in transaction session. Which context was the wrong one?

    Medium

    The context given to BeginTx owns the transaction — statement contexts abort only their own statement, so a transaction rooted at context.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. 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?

    Medium

    The outbound request was never given a contexthttp.NewRequest roots it at context.Background(), so the disconnect cancels r.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. 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?

    Medium

    Cancellation 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. 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?

    Hard

    context.WithCancelCause together with context.Cause(ctx) — the cause travels down the tree with the cancellation, while ctx.Err() keeps returning the plain sentinel every errors.Is check 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. 14

    You have to abort a third-party client that exposes Close() but takes no context.Context. A goroutine that waits on ctx.Done() and calls Close() leaks one goroutine per call. What does the standard library offer instead?

    Hard

    context.AfterFunc(ctx, f), added in Go 1.21 — it registers a callback on the context's cancellation and returns a stop function, 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. 15

    srv.Shutdown(ctx) returns context.DeadlineExceeded on 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?

    Hard

    Shutdown waits, 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. 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?

    Hard

    The 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
    …