Skip to content
C Codeloom
Go

Go Context Cancellation Patterns

Master Go's context package: propagate deadlines, cancel goroutines safely, and avoid leaks with practical patterns for HTTP, database, and pipeline code.

·5 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • How context propagates cancellation across goroutines
  • Choosing WithCancel, WithTimeout, and WithDeadline
  • Wiring context through HTTP handlers and DB calls
  • Cleaning up goroutines with select and Done
  • Common context anti-patterns to avoid

Prerequisites

  • Familiar with shell
  • Comfortable with goroutines and channels

What and Why

A long-running Go program almost always has goroutines that should stop when the request that started them goes away: the user closed the browser, the deadline passed, or an upstream RPC failed. context.Context is Go’s standard channel-shaped signal for “stop now,” carrying deadlines and cancellation across API boundaries.

Without context, you write ad hoc done-channels, leak goroutines, and burn CPU on work nobody is waiting for. With it, you get a uniform shutdown contract from net/http down to your database driver.

Mental Model

A context is an immutable value with four read-only methods: Deadline, Done, Err, and Value. Done() returns a channel that closes when the context is cancelled. Functions derive child contexts from a parent; cancelling a parent cancels every descendant, but not the other way around.

Think of it as a tree of cancellation. The root is usually context.Background() (for main) or r.Context() (for an HTTP handler). Each operation that needs its own deadline or cancellation creates a child, then must call the returned cancel function to release resources.

Hands-on Example

A handler that fans out two upstream calls and returns whichever finishes first, with a hard 2-second budget for the whole operation.

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func fetch(ctx context.Context, url string) (string, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    return resp.Status, nil
}

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    type result struct {
        from string
        body string
        err  error
    }
    out := make(chan result, 2)

    for _, u := range []string{"https://a.example", "https://b.example"} {
        u := u
        go func() {
            body, err := fetch(ctx, u)
            out <- result{from: u, body: body, err: err}
        }()
    }

    select {
    case res := <-out:
        if res.err != nil {
            http.Error(w, res.err.Error(), http.StatusBadGateway)
            return
        }
        fmt.Fprintf(w, "winner=%s status=%s\n", res.from, res.body)
    case <-ctx.Done():
        http.Error(w, ctx.Err().Error(), http.StatusGatewayTimeout)
    }
}

When the timeout fires, every in-flight http.Client.Do aborts because the request was created with NewRequestWithContext. The losing goroutine wakes up, sends its (possibly error) result into the buffered channel, and exits. No leaks.

request ctx (r.Context())
      |
      +-- WithTimeout(2s)  --> ctx (handler)
                |
                +-- goroutine: fetch A (uses ctx)
                +-- goroutine: fetch B (uses ctx)

timeout or cancel():
  ctx.Done() closes
     |
     v
  in-flight HTTP requests abort
  goroutines observe Done() and exit
Cancellation propagates from parent to child contexts across goroutines

For a worker pool, the pattern is the same but driven by context.WithCancel:

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return
        case j, ok := <-jobs:
            if !ok {
                return
            }
            process(ctx, j)
        }
    }
}

Common Pitfalls

  • Forgetting defer cancel(). WithCancel, WithTimeout, and WithDeadline all return a cancel func. If you don’t call it, the parent keeps a reference to the child until the deadline fires, leaking memory and timers.
  • Passing context.TODO() in production. TODO is for code you haven’t refactored yet. In a real path, plumb a real context from the caller.
  • Storing contexts in structs. The standard library convention is to pass ctx as the first function argument. Stashing it on a struct hides the dependency and makes cancellation hard to reason about.
  • Using Value as a grab bag. context.Value is for request-scoped data like trace IDs, not application config or dependencies.
  • Ignoring ctx.Err(). A goroutine that selects on ctx.Done() should usually return ctx.Err() so callers can distinguish cancellation from timeout.

Practical Tips

  • Always make ctx context.Context the first parameter, and never accept a nil context. Use context.Background() at the top of main or tests.
  • Set the deadline at the boundary where it makes sense (the HTTP handler, the RPC entry point), not deep inside helpers. Helpers inherit and shorten if needed.
  • For per-attempt timeouts inside a retry loop, derive a fresh child with WithTimeout each iteration so the inner deadline resets while the outer budget keeps shrinking.
  • Pair errgroup.WithContext with cancellation to coordinate multiple goroutines that should all stop when any one fails.
  • In tests, use context.WithDeadline(t.Context(), ...) (Go 1.24+) or pass t.Cleanup(cancel) to guarantee cancellation when the test exits.

Wrap-up

context.Context is the contract that lets goroutines, HTTP requests, and database calls cooperate on shutdown. Derive children for new deadlines, always call cancel, propagate ctx as the first argument, and select on Done() in any blocking loop. With those habits, your services drain cleanly, your goroutines stop leaking, and your upstream errors propagate the way the standard library expects.