Context 0 to 1

ctx context.Context is the first parameter of almost every non-trivial Go function — handlers, queries, RPC calls, workers. New Go readers see it everywhere and shrug it through without quite knowing what it’s for.

It’s for one job at this stage: telling a goroutine to stop. Deadlines and request-scoped values come later; cancellation is the load-bearing idea.

This post walks the problem from zero: a tiny program that exhibits the bug, then three small steps that arrive at idiomatic context use. The runnable code lives at prototype-code/context-go/, one cmd/<step>/main.go per idea.

Each program is intentionally bare-bones — one or two goroutines, a time.Sleep standing in for real work, no frameworks.

The bug — a goroutine you can’t stop

1
2
3
4
5
6
7
8
9
10
11
12
13
func worker() {
    for i := 0; ; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Printf("worker: tick %d\n", i)
    }
}

func main() {
    go worker()
    time.Sleep(300 * time.Millisecond)
    fmt.Println("caller: done, but worker has no idea")
    time.Sleep(200 * time.Millisecond)
}

Run it:

1
2
3
4
5
worker: tick 0
worker: tick 1
caller: done, but worker has no idea
worker: tick 2
worker: tick 3

The caller is finished. The worker keeps ticking. In a short script you’d never notice — main returns and the OS reaps the process. In a long-lived server, this is a goroutine leak: the worker stays alive past the work it was created for, holding its stack and everything it closes over, and runtime.NumGoroutine() climbs until something notices.

The bug isn’t the for loop. The bug is that there’s no way for the caller to say stop.

Fix 1 — a done channel

The smallest cancellation primitive Go has is a channel you close to broadcast “we’re done.” Receivers select on it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func worker(done <-chan struct{}) {
    for i := 0; ; i++ {
        select {
        case <-done:
            fmt.Printf("worker: cancelled after %d ticks\n", i)
            return
        case <-time.After(100 * time.Millisecond):
            fmt.Printf("worker: tick %d\n", i)
        }
    }
}

func main() {
    done := make(chan struct{})
    go worker(done)

    time.Sleep(300 * time.Millisecond)
    close(done)
    time.Sleep(50 * time.Millisecond)
}

close(done) makes every <-done receive unblock immediately with the zero value, which is why the pattern uses chan struct{} — the channel carries no data, only a signal. Closing is a broadcast, so one close stops any number of receivers.

1
2
3
worker: tick 0
worker: tick 1
worker: cancelled after 2 ticks

This works. The reason it’s not the answer for real code is that it doesn’t compose. Each function invents its own done parameter. There’s no standard way to combine a deadline with a manual cancel, or to forward “this request was cancelled” through three layers of HTTP middleware. Every project that grows past trivial reinvents the same primitive — usually worse.

Fix 2 — context.Context

context.Context is the standardization. It’s an interface with four methods; the one that matters at this stage is Done() <-chan struct{}, which returns the same kind of channel you just built — closed when work should stop.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func worker(ctx context.Context) error {
    for i := 0; ; i++ {
        select {
        case <-ctx.Done():
            return fmt.Errorf("worker stopping after %d ticks: %w", i, ctx.Err())
        case <-time.After(100 * time.Millisecond):
            fmt.Printf("worker: tick %d\n", i)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    go func() {
        if err := worker(ctx); err != nil {
            fmt.Println(err)
        }
    }()

    time.Sleep(300 * time.Millisecond)
    cancel()
    time.Sleep(50 * time.Millisecond)
}

Three new pieces:

  • context.Background() is the root. You start here in main, in tests, and at the top of HTTP handlers. (Its cousin context.TODO() behaves the same but is a lint flag meaning “I haven’t wired this through yet.”)
  • context.WithCancel(parent) returns a child context plus a cancel function. Calling cancel() closes the child’s Done() channel.
  • ctx.Err() tells the receiver why it was cancelled. Right now it’s context.Canceled. Later, with deadlines, it can be context.DeadlineExceeded. Wrap it with %w so callers can match it.
1
2
3
worker: tick 0
worker: tick 1
worker stopping after 2 ticks: context canceled

Same behavior as the done channel, almost identical code shape. What you’ve bought is the interface: every stdlib package that does I/O — net/http, database/sql, os/exec — accepts a context.Context and respects its cancellation. The handful of done channels you’d have written by hand are now one shared vocabulary.

Two rules to internalize:

  1. ctx is the first parameter, named ctx, typed context.Context. Always.
  2. defer cancel() runs even if you also call cancel() manually — it’s idempotent and safe. Forgetting it leaks the context’s internal bookkeeping.

Fix 3 — propagation

The piece you couldn’t get from a hand-rolled done channel is cascade. One cancel() at the top of a call stack stops every goroutine downstream, because each layer accepted ctx and passed it along.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func leaf(ctx context.Context, id int, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; ; i++ {
        select {
        case <-ctx.Done():
            fmt.Printf("leaf %d: stopping (%v)\n", id, ctx.Err())
            return
        case <-time.After(100 * time.Millisecond):
            fmt.Printf("leaf %d: tick %d\n", id, i)
        }
    }
}

func middle(ctx context.Context) {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go leaf(ctx, i, &wg)
    }
    wg.Wait()
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    go middle(ctx)

    time.Sleep(300 * time.Millisecond)
    cancel()
    time.Sleep(100 * time.Millisecond)
}

One cancel() stops three leaves. The middle function never had to know about cancellation logic — it just threaded the ctx it received to the children it spawned, and the rest is automatic.

1
2
3
4
5
6
7
8
9
leaf 0: tick 0
leaf 1: tick 0
leaf 2: tick 0
leaf 0: tick 1
leaf 1: tick 1
leaf 2: tick 1
leaf 0: stopping (context canceled)
leaf 1: stopping (context canceled)
leaf 2: stopping (context canceled)

This is the moment context earns its keep. Three goroutines, one shutdown signal, zero per-layer bookkeeping. A real server is the same shape, just bigger: an HTTP handler accepts r.Context(), calls a service method that accepts ctx, which fires a database query that accepts ctx, and if the client disconnects, the cancel propagates the whole way down and the query is aborted at the driver.

The rules at 0→1

  • ctx context.Context is the first parameter. Don’t put it second. Don’t store it in a struct.
  • Start with context.Background() (or context.TODO() as a placeholder).
  • Derive children with context.WithCancel(parent)WithTimeout and WithDeadline come at 1→2.
  • Always defer cancel() even if you also cancel manually.
  • Inside a loop or blocking operation, select { case <-ctx.Done(): return ctx.Err(); case <-work: ... } is the universal shape.
  • Wrap ctx.Err() with %w so callers can errors.Is(err, context.Canceled).

A few things deliberately left out for the MVP — all of which belong in the follow-up:

  • Timeouts and deadlinesWithTimeout and WithDeadline for “stop after N seconds” or “stop by this wall-clock time.”
  • errgroup — the right tool when you want N goroutines to share a context and fail-fast on the first error.
  • Request-scoped valuescontext.WithValue exists, is widely abused, and deserves its own treatment with the rules around it.
  • net/http propagation — how r.Context() cancels when the client hangs up, and what your handler should do about it.
  • Custom Context implementations — when (rarely) you’d write your own.

The four programs in this post are the floor. Once cancellation propagates correctly through a call tree, the rest of context is just specialized constructors over the same idea.

Running the examples

1
2
3
4
5
cd prototype-code/context-go
go run ./cmd/leak       # the bug — Ctrl-C to exit
go run ./cmd/donechan   # the manual fix
go run ./cmd/withcancel # the same fix, in stdlib shape
go run ./cmd/propagate  # one cancel(), three goroutines stop

Every program is under 50 lines. The point isn’t the code — it’s that the same four lines (ctx, cancel := …, defer cancel(), case <-ctx.Done():, pass ctx down) replace the entire family of ad-hoc cancellation patterns Go projects used to grow.