Context Cancellation Done Right: Propagation, Derived Contexts, and the Gotchas That Will Burn You

Every Go developer has written this code at some point:

ctx := context.Background()
result, err := db.QueryContext(ctx, "SELECT ...")

Fine for a script. Catastrophic in a production service. That context.Background() will never cancel. Your query will run until the DB times out, the connection pool fills up, and your service starts returning 503s while your on-call phone lights up at 3 AM.

Context cancellation is one of those things that looks simple in tutorials and bites hard in production. The package itself is small — six functions, two types, a handful of values. The failure modes are not small.

This article is about understanding cancellation deeply enough that you stop being afraid of it and start designing systems around it.

The Core Model: A Tree of Contexts

Before anything else, burn this into your brain: contexts form a tree, and cancellation always flows downward — from parent to child, never the other way.

When you call context.WithCancel(parent), you get back a child context and a cancel function. When you call that cancel function (or when the parent gets cancelled), every descendant of that child is cancelled too. The parent? Completely unaffected.

root := context.Background()

parent, cancelParent := context.WithCancel(root)
child, cancelChild := context.WithCancel(parent)
grandchild, cancelGrandchild := context.WithCancel(child)

// Cancel the middle one
cancelChild()

// child is done: true
// grandchild is done: true  (cancelled by propagation)
// parent is done: false     (unaffected, above the cancellation point)

This isn’t an implementation detail — it’s the entire design philosophy. You pass a context into a function to give it a way to be told "stop what you’re doing." You never hand a cancel function to the code you’re calling. Control flows down; cancellation signals flow down.

The Four Derivation Functions (and When to Use Each)

context.WithCancel

Returns a copy of the parent with a cancel function. Use this when you need manual control — you’ll cancel it explicitly when work is done or when an error short-circuits.

Classic use case: fan-out where the first result wins.

func fetchFirstResult(ctx context.Context, urls []string) (string, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // crucial — always defer cancel

    results := make(chan string, len(urls))

    for _, url := range urls {
        go func(u string) {
            resp, err := fetchURL(ctx, u)
            if err != nil {
                return
            }
            results <- resp
        }(url)
    }

    select {
    case r := <-results:
        return r, nil // cancel() fires via defer, kills remaining goroutines
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

The defer cancel() on line three is not optional. Without it, the context lives until the parent is cancelled. In a long-running service that’s processing thousands of requests, you leak goroutines and memory. Leak enough of them and you’re paging again.

context.WithTimeout

Shorthand for WithDeadline(parent, time.Now().Add(timeout)). Gives you a context that cancels automatically after a duration.

func callExternalService(ctx context.Context, payload []byte) error {
    // External service has a 5-second SLA. We enforce it.
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    return client.Post(ctx, "/api/ingest", payload)
}

Gotcha #1: timeout is not a guarantee, it’s a best effort.

WithTimeout sets a deadline. Whether your code actually respects it depends on whether the libraries you call check ctx.Done(). A blocking syscall, a CGo call, or a library that doesn’t accept a context won’t be interrupted. The context fires, ctx.Err() returns context.DeadlineExceeded, but the underlying operation is still running in a thread somewhere.

context.WithDeadline

Same as WithTimeout but you specify an absolute time instead of a duration. Use this when you’re coordinating across systems and the deadline is externally imposed — a cron window, a batch job cutoff, a distributed transaction timeout.

// Process everything before midnight
deadline := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, time.UTC)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()

Gotcha #2: deadline stacking.

When you derive a context with a longer timeout from a parent that already has a shorter deadline, the child inherits the shorter deadline. It cannot extend the parent’s constraint.

parent, _ := context.WithTimeout(context.Background(), 1*time.Second)

// This doesn't give you 10 seconds. You still have at most 1 second.
child, _ := context.WithTimeout(parent, 10*time.Second)

Use ctx.Deadline() to check what you actually have before creating derived contexts.

context.WithValue

Attach request-scoped values to a context. This is the most misused function in the package.

type contextKey string

const (
    keyRequestID contextKey = "request_id"
    keyUserID    contextKey = "user_id"
)

ctx = context.WithValue(ctx, keyRequestID, requestID)

The key type matters. Always use an unexported custom type as your key, never a plain string. Two packages using the plain string "user_id" as a key will clobber each other silently. An unexported type is package-local by definition.

WithValue doesn’t carry cancellation — it only attaches data. The resulting context still cancels when its parent does.

How Propagation Actually Works

The runtime doesn’t poll contexts. It uses Go’s channel select mechanism internally. When a parent context is cancelled, it closes the Done() channel on each of its registered children. Those children then close their own Done() channels, propagating the signal down the tree in a cascade.

This means propagation is asynchronous in wallclock time but deterministic in the happens-before sense. The moment you call cancel(), the parent is done and all children will see their Done() channel close before they run any more code that checks it.

Your code participates in this mechanism by checking ctx.Done() in select statements:

func processItems(ctx context.Context, items []Item) error {
    for _, item := range items {
        // Check cancellation before each unit of work
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }

        if err := process(ctx, item); err != nil {
            return err
        }
    }
    return nil
}

The select with a default case is a non-blocking check. It costs almost nothing and keeps your loop responsive to cancellation without adding latency to the happy path.

Passing Context Through: The Rules

Rule 1: Context is always the first parameter

// Correct
func FetchUser(ctx context.Context, userID string) (*User, error)

// Wrong
func FetchUser(userID string, ctx context.Context) (*User, error)

// Also wrong — don't hide it in a struct
type Fetcher struct {
    ctx context.Context
}

Storing a context in a struct is an anti-pattern. Contexts are request-scoped. A struct outlives a request. If you store a context in a struct, you end up with a cancelled context being used for the next operation, or worse, a context from request A leaking into request B.

The one exception: types that implement interfaces defined in other packages where you have no control over the signature. Wrap it and document the limitation.

Rule 2: Never pass nil as a context

// This panics at runtime, not compile time
fetchUser(nil, "user-123")

// If you have no context, use context.Background() or context.TODO()
fetchUser(context.Background(), "user-123")

context.TODO() is semantically "I haven’t decided what context to use here yet." It’s a legitimate placeholder during refactoring but should not appear in shipped code.

Rule 3: Don’t cancel a context that was passed to you

If a function receives a ctx context.Context parameter, it is a consumer of that context, not an owner. Call context.WithCancel(ctx) to get your own child context if you need one. Never derive a cancel function from a context you didn’t create.

Gotchas That Actually Hurt in Production

Gotcha #3: Goroutines that don’t listen.

Launching a goroutine with a context doesn’t automatically stop the goroutine when the context cancels. The goroutine has to actively check.

// This goroutine will run forever regardless of ctx cancellation
go func() {
    for {
        doHeavyWork()
        time.Sleep(1 * time.Second)
    }
}()

// This goroutine respects cancellation
go func() {
    for {
        select {
        case <-ctx.Done():
            return
        case <-time.After(1 * time.Second):
            doHeavyWork()
        }
    }
}()

Gotcha #4: Forgetting to check ctx.Err() after a Done() fire.

When ctx.Done() closes, you need to know why. context.Canceled means an upstream caller decided to stop. context.DeadlineExceeded means time ran out. These are different failure modes and may require different responses — log levels, retry logic, error codes sent to clients.

select {
case <-ctx.Done():
    switch ctx.Err() {
    case context.Canceled:
        log.Info("request cancelled by caller")
        return ErrCancelled
    case context.DeadlineExceeded:
        log.Warn("request timed out", "deadline", deadline)
        return ErrTimeout
    }
}

Gotcha #5: HTTP client context vs server context.

In an HTTP handler, the incoming request carries a context that is cancelled when the client disconnects. This is great for cancelling expensive queries when a browser tab closes.

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    result, err := db.QueryContext(ctx, "SELECT ...")
    // If the client disconnects, ctx is cancelled and the query aborts.
}

But: if you launch a background goroutine from a handler to do work that should outlive the HTTP request (sending an email, running an async job), you cannot use the request’s context. The moment the response is sent, the context is cancelled.

func handler(w http.ResponseWriter, r *http.Request) {
    // Accept the request
    jobID := createJob(r.Body)
    w.WriteHeader(http.StatusAccepted)
    json.NewEncoder(w).Encode(jobID)

    // DON'T do this — r.Context() is cancelled when handler returns
    go processJob(r.Context(), jobID)

    // DO this — detach from request lifecycle
    go processJob(context.Background(), jobID)
    // Or better: hand it to a worker pool with its own context
}

Gotcha #6: Context values are not typed.

ctx.Value(key) returns interface{}. You will type-assert it every time you read it. If the value isn’t there, you get nil. If the type assertion is wrong, you panic.

Write accessor functions:

func RequestIDFromContext(ctx context.Context) (string, bool) {
    v, ok := ctx.Value(keyRequestID).(string)
    return v, ok
}

Never type-assert directly at the call site. Never assume the value is there.

Gotcha #7: Passing context through channels.

This one is subtle. If you send a context through a channel, the receiver operates on a context that was created by — and may be cancelled by — the sender. That’s usually wrong. The receiver should derive its own context from an appropriate root.

Production-Ready Patterns

Timeout budgeting across service calls

In a microservices architecture, each hop should consume a portion of the remaining budget, not set its own independent timeout.

func (s *Service) HandleRequest(ctx context.Context, req Request) (Response, error) {
    deadline, ok := ctx.Deadline()
    if !ok {
        // No deadline set upstream — enforce a sane default
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
        defer cancel()
        deadline, _ = ctx.Deadline()
    }

    // Leave 20% of remaining budget for our own processing after the calls
    remaining := time.Until(deadline)
    callBudget := time.Duration(float64(remaining) * 0.8)

    callCtx, cancel := context.WithTimeout(ctx, callBudget)
    defer cancel()

    return s.downstream.Call(callCtx, req)
}

Graceful shutdown with context

A clean shutdown pattern that gives in-flight work a chance to complete:

func main() {
    rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{Addr: ":8080", Handler: buildRouter()}

    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    <-rootCtx.Done()
    log.Info("shutting down, draining connections...")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Error("forced shutdown", "err", err)
    }
}

Note: the shutdownCtx is derived from context.Background(), not from the signal context that’s already done. Once rootCtx is cancelled, you can’t derive a working child from it.

Context-aware worker pool

type Pool struct {
    work chan func(ctx context.Context)
    wg   sync.WaitGroup
}

func NewPool(ctx context.Context, workers int) *Pool {
    p := &Pool{work: make(chan func(ctx context.Context), workers*2)}

    for range workers {
        p.wg.Add(1)
        go func() {
            defer p.wg.Done()
            for {
                select {
                case fn, ok := <-p.work:
                    if !ok {
                        return
                    }
                    fn(ctx)
                case <-ctx.Done():
                    return
                }
            }
        }()
    }

    return p
}

func (p *Pool) Submit(fn func(ctx context.Context)) {
    p.work <- fn
}

func (p *Pool) Stop() {
    close(p.work)
    p.wg.Wait()
}

When the pool’s context is cancelled — say, during shutdown — all workers drain and return. No goroutine leaks.

Testing context cancellation

Don’t just test the happy path. Test that your code behaves correctly when the context is already cancelled on entry.

func TestQueryCancellation(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel() // cancel immediately

    _, err := store.FetchUser(ctx, "user-123")

    if !errors.Is(err, context.Canceled) {
        t.Errorf("expected context.Canceled, got %v", err)
    }
}

func TestQueryTimeout(t *testing.T) {
    // Use a very short timeout to force expiry
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
    defer cancel()

    time.Sleep(1 * time.Millisecond) // ensure deadline has passed

    _, err := store.ExpensiveQuery(ctx)

    if !errors.Is(err, context.DeadlineExceeded) {
        t.Errorf("expected DeadlineExceeded, got %v", err)
    }
}

The Mental Model to Keep

Every function that does I/O, makes a network call, runs a query, or loops over work should accept a context. Every function that spawns goroutines should pass that context down and check ctx.Done() in its event loops. Every function that creates a derived context must defer its cancel.

That’s the whole model. The tree propagates downward, cancellation propagates downward, and your code participates by checking Done() and returning ctx.Err() when it fires.

Get this right and you get free timeouts, free graceful shutdown, and free client-disconnect handling. Get it wrong and you get goroutine leaks, zombie queries, and services that look up but are internally falling apart.

The context package doesn’t do anything magic. It’s a mechanism for threading a cancellation signal through your call stack without changing every function signature to take an explicit "stop now" boolean. Use it everywhere. Defer every cancel. Check errors properly. The rest follows.

Leave a comment

👁 Views: 9,556 · Unique visitors: 14,506