Skip to content

Go context.Context Cheat Sheet: Cancellation, Timeouts & Gotchas

Go context.Context Cheat Sheet: Cancellation, Timeouts & Gotchas

A handler returns 200, and the goroutine it spawned never dies. Nobody called defer cancel(), so the goroutine outlives the request. Multiply by 10,000 requests per second and the process leaks goroutines until it's OOMKilled. It's the single most common goroutine leak there is.

The five constructors you need

[Go context]

Every context.Context tree starts with one of two roots. Everything else is a child wrapper.

Key Points

Background() is your tree root. WithCancel, WithTimeout, and WithDeadline leak without defer cancel(). Never pass nil, store context in a struct, or use Value for function arguments.

  • Always pair WithCancel/Timeout/Deadline with defer cancel() to prevent leaks
  • Use WithTimeout for RPC calls; WithDeadline for splitting parent budgets
  • Pass context as the first parameter; never cache it in a struct
graph TD
    BG["context.Background()"] --> WC["WithCancel(bg)"]
    BG --> WT["WithTimeout(bg, 5s)"]
    WC --> WV["WithValue(wc, key, val)"]
    WC -->|"spawns"| G1["goroutine A<br/>← ctx.Done()"]
    WT -->|"spawns"| G2["goroutine B<br/>← ctx.Done()"]
    WV -->|"spawns"| G3["goroutine C<br/>ctx.Value(key)"]

Constructor reference

ctx := context.Background()                                  // root, never cancelled
ctx := context.TODO()                                        // placeholder, don't ship
ctx, cancel := context.WithCancel(parent)                    // manual cancellation
ctx, cancel := context.WithTimeout(parent, 2*time.Second)    // relative deadline
ctx, cancel := context.WithDeadline(parent, absoluteTime)    // absolute deadline
ctx := context.WithValue(parent, key, val)                   // request-scoped value

Background() is the root in main, tests, and background workers. TODO() signals refactoring—never deploy it. All cancel-returning constructors must have defer cancel() on the next line. Missed cancel() leaks a goroutine per request.

How cancellation propagates

A context is a tree. When a parent cancels, every descendant sees Done() close simultaneously — one signal fans out across every goroutine that branched off.

graph TD
    A["handler<br/>Background()"] --> B["WithTimeout 2s<br/>client.Get()"]
    A --> C["WithCancel<br/>spawner"]
    C --> D["db.Query()"]
    C --> E["cache.Set()"]
    C --> F["publish()"]
    B -. deadline fires .-> X(("Done() closes<br/>this subtree"))
    C -. cancel() called .-> Y(("Done() closes<br/>all 3 goroutines"))

Every leaf goroutine must watch <-ctx.Done() alongside its real work. Otherwise cancellation can't reach it and the goroutine leaks.

Cancellation and timeouts

Use WithCancel for explicit cancellation. Use WithTimeout for RPC calls (2s budget). Use WithDeadline when splitting a parent budget across sequential calls:

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
resp, err := client.Get(ctx, "/orders/42")
if errors.Is(err, context.DeadlineExceeded) {
    return http.StatusGatewayTimeout, nil
}

A mistake that recurs in production: never create a fresh context.Background() with WithTimeout inside a request handler—you orphan the request context and lose client disconnect signals.

Request-scoped values

WithValue stores per-request data (request IDs, trace spans, user) across API boundaries. Not for function arguments.

type ctxKey int
const requestIDKey ctxKey = iota
 
func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}
 
func RequestID(ctx context.Context) string {
    v, _ := ctx.Value(requestIDKey).(string)
    return v
}

Always use a private unexported key type (not string). Exported keys collide. Type-assert; never assume presence.

HTTP and gRPC integration

[Go net/http]

HTTP handlers get a request context via r.Context(). Wrap it with WithTimeout for your I/O budget:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
    defer cancel()
    rows, err := db.QueryContext(ctx, "SELECT id FROM orders WHERE user_id=$1", userID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()
}

For outbound HTTP, use http.NewRequestWithContext(ctx, ...). For gRPC, methods take ctx as the first parameter. Deadlines propagate as grpc-timeout headers automatically.

Common gotchas

  • Store context in a struct — Contexts live per-call, not per-object[Go context]. Pass it explicitly to every method.
  • Pass nil context — Causes a panic. Use context.TODO() as a placeholder.
  • Forget defer cancel()WithCancel, WithTimeout, WithDeadline leak if not cleaned up[Go context].
  • Use Value for optional arguments — Unreadable and untyped. Pass as a regular function parameter.
  • Ignore ctx.Err() after <-ctx.Done()Done() fires for both cancellation and timeout. Call ctx.Err() to distinguish: context.Canceled → 499 (client gone), DeadlineExceeded → 504 (too slow).

Detecting context leaks in tests and production

The runtime.NumGoroutine() count is the cheap canary. Sample it before and after a test; non-zero delta = leak. The goleak package automates this:

import "go.uber.org/goleak"
 
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}
 
// per-test guard — fails the test if any goroutine outlives this function
func TestProcessOrder(t *testing.T) {
    defer goleak.VerifyNone(t)
    // ... test body
}

In production, client_golang's default Go collector already exports go_goroutines for free — scrape that. To expose the count yourself (e.g. a custom registry without that collector), use a GaugeFunc with a name that can't collide with the built-in:

var goroutines = promauto.NewGaugeFunc(
    prometheus.GaugeOpts{Name: "app_goroutines_live"},
    func() float64 { return float64(runtime.NumGoroutine()) },
)

Alert when the goroutine count grows monotonically over an hour — that pattern is almost always a forgotten defer cancel() or a goroutine waiting on a channel that nobody closes.

Cancellation propagation rules

A few rules that catch >90% of real-world bugs: [Go context]

  1. Always pass ctx as the first parameter of any function that does I/O, blocks on a channel, or calls another ctx-aware function.
  2. Never wrap ctx inside another struct with the goal of "saving keystrokes." The containedctx linter flags a context.Context held in a struct field. (staticcheck SA1029 is a different check — it flags built-in types used as WithValue keys, the rule behind the unexported-key-type advice above.)
  3. select { case <-ctx.Done(): } everywhere that you would otherwise block. A for loop reading from a channel becomes:
for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case msg := <-msgs:
        if err := process(ctx, msg); err != nil {
            return err
        }
    }
}
  1. Cancel-on-parent: WithCancel(parent) cancels when parent cancels — wire your tree so one root cancellation drains the whole stack.
  2. Don't recreate roots inside the request path. context.Background() belongs in main() and a few cron entry points; everything downstream derives from the request ctx.

What ctx.Err() returns and why it matters

After <-ctx.Done() fires, call ctx.Err() to know why the context cancelled:

Return valueTriggerHTTP mappinggRPC mapping
nilContext still live — Done() not yet closed (a closed Done() always yields a non-nil Err())n/an/a
context.CanceledCaller invoked cancel() or parent canceled499 (client closed request)codes.Canceled
context.DeadlineExceededDeadline reached (WithTimeout, WithDeadline)504 (gateway timeout)codes.DeadlineExceeded

The HTTP 499 status code is non-standard but supported by Nginx and most observability tools[Go context]. Use it to distinguish "client gave up" (499) from "we ran out of budget" (504) — both metrics matter for SLO tracking, and conflating them masks real availability problems.

For Go 1.20+, WithCancelCause(parent) and context.Cause(ctx) let you attach an error to the cancellation, which propagates through the chain. Use it sparingly — most code paths only care about the canceled-vs-timed-out distinction above.

Pick the right constructor for the symptom

When a context bug bites in production, the question is "what kind of cancellation do I want." Route by intent, not by API name:

graph TD
    Need[I need to control<br/>a downstream operation] --> Q{Why am I<br/>cancelling?}
    Q -->|Caller might give up| Cancel[WithCancel<br/>+ defer cancel<br/>good for fan-out]
    Q -->|Operation has SLA| Timeout[WithTimeout<br/>+ defer cancel<br/>500ms RPC budget]
    Q -->|Need budget split<br/>across multiple steps| Deadline[WithDeadline<br/>+ defer cancel<br/>parent budget minus elapsed]
    Q -->|Carrying request data<br/>not cancellation| Value[WithValue<br/>request ID, user ID,<br/>trace span]
    Q -->|Multiple cancellation<br/>causes| Cause[WithCancelCause<br/>Go 1.20 plus<br/>read with context.Cause]
    Cancel --> Always[Always call cancel<br/>even on success path]
    Timeout --> Always
    Deadline --> Always
    Cause --> Always
    Value -.->|cancel propagates| Cancel
    style Always fill:#dfd
    style Value fill:#ffd

Every "why is my goroutine leaking" trace lands on a missing defer cancel() or a stored-in-struct context.[Go context]

Production gotchas the linter will not catch

Static analysis flags the obvious leaks. Three subtler patterns survive code review:

Type-assert with the comma-ok form, every time

The single-return lookup compiles, runs, and silently hands you the zero value when the key is absent or mistyped — the fix is comma-ok plus a loud fallback:

type ctxKey int
 
const (
    requestIDKey ctxKey = iota
    tenantIDKey
)
 
// Bad: the assertion silently drops to "" if the key is missing or
// the value is the wrong type. Downstream logs show empty strings.
func badLookup(ctx context.Context) string {
    return ctx.Value(requestIDKey).(string)
}
 
// Good: explicit comma-ok branch and a sentinel so a missing value
// is loud, not silent. Production telemetry can alert on the sentinel.
func RequestID(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(requestIDKey).(string)
    if !ok || id == "" {
        return "", false
    }
    return id, true
}
 
func handleOrder(ctx context.Context, orderID string) error {
    rid, ok := RequestID(ctx)
    if !ok {
        // Loud failure rather than logs full of empty request IDs.
        return fmt.Errorf("missing request id in ctx for order %s", orderID)
    }
    log.Printf("rid=%s order=%s", rid, orderID)
    return nil
}

Same rule for tenant IDs, identities, locales, feature flags — a helper hiding the bool will eventually mask a cross-service wiring bug.

Wire the signal handler into the cancel chain, not next to it

The classic shutdown bug: server, signal handler, and workers on three parallel context roots that never meet — SIGTERM returns from main while in-flight handlers die mid-write. Root everything in one signal.NotifyContext:

func main() {
    ctx, stop := signal.NotifyContext(context.Background(),
        os.Interrupt, syscall.SIGTERM)
    defer stop()
 
    srv := &http.Server{
        Addr:    ":8080",
        Handler: router(),
        // BaseContext returns the parent for every incoming request,
        // so SIGTERM cancels in-flight handler ctxs as well.
        BaseContext: func(_ net.Listener) context.Context { return ctx },
    }
 
    go func() {
        if err := srv.ListenAndServe(); err != nil &&
            !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("listen: %v", err)
        }
    }()
 
    <-ctx.Done()
    shutdownCtx, cancel := context.WithTimeout(
        context.Background(), 25*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("graceful shutdown failed: %v", err)
    }
}

signal.NotifyContext (Go 1.16+) is the correct entry point; the manual signal.Notify-channel-plus-cancelFunc pattern is the classic source of orphaned goroutines on shutdown.

Propagate ctx through OpenTelemetry, not around it

Trace propagation needs the same context that cancels the request. Two leaks: spans started from Background() (disconnected trace) and span pointers stored outside the context (writes silently no-op after cancel):

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/trace"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
 
func wireRouter(h http.Handler) http.Handler {
    return otelhttp.NewHandler(h, "http.server")
}
 
func handleCheckout(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    tracer := otel.Tracer("checkout")
    ctx, span := tracer.Start(ctx, "checkout.validate")
    defer span.End()
 
    // Pass the derived ctx everywhere — never start a child span
    // from Background() or you orphan the trace.
    if err := validate(ctx, r); err != nil {
        span.RecordError(err)
        http.Error(w, "invalid", http.StatusBadRequest)
        return
    }
    if err := charge(ctx, r); err != nil {
        span.RecordError(err)
        http.Error(w, "payment failed", http.StatusBadGateway)
        return
    }
    w.WriteHeader(http.StatusOK)
}

For goroutines that outlive the request (async email, background export), build a fresh root from Background() and attach a span link with trace.WithLinks — trace topology preserved without binding the worker to the inbound deadline.

Choosing context.Context vs sync.WaitGroup

WaitGroup answers "is everyone done?"; context answers "should you stop?". Use context to broadcast cancel, WaitGroup to wait for clean exit — real services need both.

QuestionReach forWhy
When should this goroutine give up?context.ContextCarries deadline, cancel signal, and request values
How do I know all spawned goroutines drained?sync.WaitGroupCounts up on Add, blocks on Wait until all Done
One goroutine errors — do the rest abort?errgroup.GroupWraps WaitGroup with first-error short-circuit and shared ctx
Bound concurrency to N workerserrgroup.SetLimit or buffered channelContext alone has no concurrency limit
Long-running fan-out with per-task deadlineContext + errgroup per taskParent ctx for global cancel, child ctx per task budget

errgroup.WithContext merges both into one API — the right default for new fan-out code.

import "golang.org/x/sync/errgroup"
 
func fetchAll(parent context.Context, urls []string) ([][]byte, error) {
    g, ctx := errgroup.WithContext(parent)
    g.SetLimit(8) // bounded concurrency
 
    out := make([][]byte, len(urls))
    for i, u := range urls {
        i, u := i, u // pin for closure
        g.Go(func() error {
            // Inherit the group's ctx so the first error cancels siblings.
            req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                return fmt.Errorf("fetch %s: %w", u, err)
            }
            defer resp.Body.Close()
            body, err := io.ReadAll(resp.Body)
            if err != nil {
                return err
            }
            out[i] = body
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return out, nil
}

The group ctx cancels on the first non-nil error; siblings see Done() close and abort cleanly. Hand-rolling this (WaitGroup + WithCancel + sync.Once) gives every line a place for a leak to hide.

Frequently Asked Questions

When should I call cancel() if I'm deriving a context in a loop?

Call cancel() immediately after each step finishes, before the next iteration. Do not defer once per loop — it holds all cancels until the loop exits.

for _, url := range urls {
    ctx, cancel := context.WithTimeout(parent, 1*time.Second)
    body, err := fetch(ctx, url)
    cancel() // release immediately
    if err != nil { return err }
}

Is context.TODO() safe to ship?

No. go vet will not flag it, but it's a signal to reviewers that a real context was not threaded. Replace it before merging to main.

Why shouldn't I store context in a struct like Handler.ctx?

Because the context is tied to a single request's lifetime. If you cache it in a struct that lives longer (like a handler instance), it will be cancelled after the first request, and all subsequent requests will use a dead context.

What's the difference between DeadlineExceeded and Canceled?

DeadlineExceeded means the deadline passed (timeout). Canceled means someone called cancel() or the parent context was canceled. Map them to different HTTP status codes: 504 and 499, respectively.

Constructor reference table

A quick comparison of the five constructors and when to reach for each:

ConstructorWhen to useCancellation sourceMust defer cancel()?
Background()Top of main, server startupNeverNo
TODO()Placeholder during refactorNeverNo
WithCancel(parent)Caller might give up earlyExplicit cancel() callYes
WithTimeout(parent, d)RPC budget, request SLATimer expiry or cancel()Yes
WithDeadline(parent, t)Splitting a parent budgetDeadline reached or cancel()Yes
WithValue(parent, k, v)Carrying request-scoped dataInherits parentNo
WithCancelCause(parent)Need cause attribution (Go 1.20+)cancel(err) with causeYes

Keep Reading

Was this article helpful?

Your feedback directly shapes our editorial depth and technical accuracy.

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

An independent engineering publication covering distributed systems, databases, and production infrastructure. Every factual claim is cited to a primary source or removed.

Read Next