Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

threading context.Context through, and finally understanding why

How I went from resenting context.Context as Go boilerplate to relying on it for cancellation and deadlines, with the patterns and mistakes that got me there.

Code on a screen

For my first couple of years writing Go I treated context.Context as a tax. It was the first argument of every function, it propagated like a virus through the codebase, and I added it because the linter and the reviewers told me to, not because I understood what it bought me. If that's where you are, this is the post I wish I'd read.

The thing that finally made it click: a Context is not a bag of values. It's a cancellation signal with a deadline attached, and the values are the least important part of it. Once you see it as "the way a caller tells everything downstream to stop", the whole design stops feeling like ceremony.

The problem it solves

Imagine a request comes in. It fans out to a database query, two HTTP calls to internal services, and a bit of CPU work. Then the client hangs up. Without a shared cancellation mechanism, all of that work carries on, talking to a socket nobody is listening to, holding a database connection, burning a goroutine. Multiply by a few thousand abandoned requests and you've got a server doing enormous amounts of work for results that will be thrown away.

Context is the wire that carries "stop" from the top of that tree to the bottom. You cancel once, at the root, and every well-behaved function below it that's blocked on I/O gets woken up and returns early. That's the whole pitch, and it's a good one.

Programming on a laptop

Threading it through

The rules are simple to state and take a while to internalise:

  • ctx is the first parameter, always named ctx, always context.Context. Don't store it in a struct. It travels as an argument because its lifetime is the call, not the object.
  • The top of the tree creates the root. An HTTP handler gets one from r.Context(). A main or a worker creates one, usually with a deadline or a cancel.
  • Everything in the middle just passes the ctx it received straight down, or derives a child from it.

Deriving children is where the actual power lives:

func fetchUser(ctx context.Context, id string) (*User, error) {
    // give this specific call 2 seconds, but still respect
    // any earlier deadline already on ctx
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    return db.QueryUser(ctx, id)
}

The lovely bit is that WithTimeout returns a context that fires at whichever deadline comes first: the two seconds you just set, or the deadline already inherited from the caller. You can't accidentally make a child outlive its parent. The tree only ever gets more impatient as you go down it, never less.

That defer cancel() is not optional. WithCancel and WithTimeout allocate resources, a goroutine and a timer, that only get released when the cancel function is called. Skip it and you leak. The linter that nags you about it is right, and I've chased down enough slow goroutine leaks to stop arguing with it.

Actually respecting cancellation

Threading the context through is only half the job. The other half is checking it. A function that takes a ctx and never looks at it is theatre.

For anything that does I/O through a standard library or a decent driver, you get this for free: pass ctx to db.QueryContext, http.NewRequestWithContext, and friends, and they'll abandon the operation when it's cancelled. For your own long-running loops, you check explicitly:

func process(ctx context.Context, items []Item) error {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        if err := handle(item); err != nil {
            return err
        }
    }
    return nil
}

ctx.Err() tells you why it stopped: context.Canceled if someone called cancel, context.DeadlineExceeded if a timeout fired. Returning that error rather than swallowing it is what lets the layers above distinguish "the client gave up" from "this genuinely failed", which matters enormously when you're deciding whether to log an error or shrug.

The mistakes I made

A short catalogue of things I got wrong so you don't have to:

  • Storing ctx in a struct. Tempting, feels tidy, breaks the lifetime model immediately. The context belongs to a call, not a long-lived object. The go vet warning is correct.
  • Using context.Background() halfway down the tree because passing the real one was awkward. This silently severs cancellation: everything below that point ignores the deadline you carefully set up top. It's the bug that looks fine until production gets busy.
  • Abusing context.WithValue. It's there for request-scoped data that genuinely crosses API boundaries, like a request ID for tracing. It is not a way to avoid having function parameters. I shoved configuration through it once and was rightly mocked.

Where I landed

I now reach for context first, not last. When I sketch a new function that does anything that could block, the ctx context.Context parameter goes in before the logic does, because retrofitting cancellation into a finished call tree is genuinely miserable and adding it up front costs nothing.

It went from the most resented bit of boilerplate in the language to something I'd badly miss. The trick was realising it isn't decoration. It's the standard library's answer to a real and ugly problem, hanging work, and once you've watched a server stay responsive under load because the cancellation actually propagated, you stop minding the extra argument. You start being grateful for it.