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

context.context, and learning to thread it through

A practical walk through Go's context package, what it is actually for, the mistakes I made first, and where the value really shows up in real services.

A code editor showing Go source on a dark theme

For my first year writing Go I treated context.Context as ceremony. Every function in the standard library wanted one as its first argument, so I passed one along to keep the compiler and the linter happy, and I never once thought about what it was carrying. It was a parameter I threaded through because everyone else did. That is exactly the wrong way to hold it, and it took a production incident to teach me better.

what it is actually for

A context is, at heart, two things bundled together: a signal that says "stop, the work is no longer wanted", and an optional bag of request-scoped values. The first of those is the one that matters and the one I ignored for too long. When a request comes into your server, that request has a lifetime. The client might give up. The handler might time out. Something upstream might decide twenty seconds is long enough. Context is how that decision propagates down through every function call the request triggered, so that work nobody is waiting for can be abandoned instead of grinding on.

The mental model that finally made it click for me: a context is a piece of paper that travels with the request, and on that paper is a tap that anyone holding the original can turn off. Everyone downstream can see when the tap is closed. That is ctx.Done(), a channel that closes when the request should stop.

func fetchUser(ctx context.Context, id string) (*User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    // ...
}

The important detail there is NewRequestWithContext. If the request that triggered this is cancelled, the outbound HTTP call is cancelled too. No extra plumbing, no manual checks. The cancellation flows because the context flowed. That is the whole point, and it is why every blocking call in the standard library takes one.

the mistakes I made first

The first mistake was storing things in context that had no business there. Context values are meant for data that is genuinely tied to the request and crosses API boundaries: a request ID, an auth token, a trace span. They are not a substitute for function arguments. I caught myself stuffing a database handle in there once, reaching for ctx.Value("db") deep in some helper, and the moment I wrote it I knew it was wrong. If a function needs a database, give it a database. Hiding dependencies in an untyped bag just to avoid threading a parameter is how you build something nobody can follow.

The second mistake was worse and it was the one that paged me. I had a handler that kicked off a background job and returned. The job used the request's context. The request finished, the context was cancelled, and the job died half way through, every time, silently. I had threaded context too far. The job did not belong to the request's lifetime, so it should never have inherited the request's cancellation. The fix was to derive a fresh context for work that outlives the request, not to keep passing the inherited one out of habit.

A diagram-style image of branching code paths

deadlines and timeouts

Once cancellation made sense, deadlines were the obvious next step. You rarely want a call to hang forever. You want it to wait a sensible amount of time and then give up, and you want that limit to apply to the whole chain of calls underneath, not each one separately.

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

result, err := slowThing(ctx)
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        // handle the timeout specifically
    }
    return err
}

Two things I had to learn the hard way here. Always call cancel, even on the timeout path, or you leak the timer and the context until it fires. The defer cancel() line is not optional decoration. And a deadline set high in the stack is inherited by everything below it: if the handler has two seconds, and it calls something that sets its own three-second timeout, the inner call still dies at two, because the parent's deadline wins. That is the correct behaviour and it is genuinely useful, but it surprised me the first time I watched a "three second" call time out at two.

where it actually pays off

The value shows up at the edges, under load, in the cases you do not test by hand. A client disconnects and your server stops the database query it no longer needs, freeing a connection. A dependency goes slow and your timeout fires cleanly instead of letting requests pile up until the whole service tips over. A trace ID rides along in the context so your logs line up across five services without you passing it through forty function signatures by hand.

None of that is visible when everything is healthy, which is precisely why I undervalued it for a year. Context is insurance. It is the difference between a slow dependency causing a blip and a slow dependency causing an outage, and you only see the difference on the bad day.

So thread it through, but thread it with intent. Take it as the first argument, pass it to every blocking call, derive a new one when the work changes lifetime, and keep the values bag for the few things that genuinely belong to the request. Stop treating it as ceremony. It is doing real work, even when, especially when, you are not looking.