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

threading context.Context through everything, and why it earns its keep

A working tour of Go's context.Context, why it has to be threaded explicitly through every layer, the mistakes I made fighting that, and the cancellation and deadline behaviour that makes the boilerplate worth it.

A terminal with goroutines and cancellation traces

The first time I met context.Context in Go I treated it as ceremony. Every function suddenly wanted ctx context.Context as its first argument, it propagated up the call tree like ivy, and I could not see what it bought me. So I did what a lot of people do at that stage: I stuffed context.Background() in wherever the compiler demanded a context and moved on. It compiled. It ran. It was also, I now realise, quietly broken in exactly the way contexts exist to prevent.

The point of context.Context is one boring, important thing: it carries a cancellation signal and a deadline down through a call tree, so that when the work at the top is no longer wanted, the work at the bottom can find out and stop. That is the whole game. Everything else, the value bag, the various constructors, is in service of that.

The shape of the problem

Picture a typical HTTP handler. A request comes in, you call a service, the service queries a database, maybe it also calls another service over the network. That is a chain four or five frames deep. Now the client gives up and closes the connection. Without a context, every layer below the handler keeps going. The database query runs to completion. The downstream call waits for its reply. You burn a connection, a goroutine, and some database time producing a result that nobody will ever read.

net/http gives you a per-request context that is cancelled the moment the client disconnects. The job is to thread it down so that cancellation actually reaches the work.

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    user, err := h.svc.LookupUser(ctx, idFromPath(r))
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(user)
}

That ctx has to keep travelling.

func (s *Service) LookupUser(ctx context.Context, id string) (*User, error) {
    return s.db.QueryUser(ctx, id)
}

func (d *DB) QueryUser(ctx context.Context, id string) (*User, error) {
    row := d.pool.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
    // ...
}

The reason it has to be passed by hand, rather than living in some ambient global, is the thing I resented and now respect. Go has no thread-local storage, on purpose. A context is tied to a specific call tree, not to a goroutine and not to a process. If it were a global, you could not have two requests in flight with different deadlines, and you certainly could not cancel one without cancelling the other. The explicitness is the feature. It is also why ctx is conventionally the first parameter: it makes the omission obvious at a glance.

The mistakes I made

My first mistake was storing a context in a struct. It felt tidy: hang the context off the service, stop passing it everywhere. It is wrong, and the standard library documentation says so in as many words. A context describes the lifetime of a single call, not of a long-lived object. Park one in a struct and you have either a context that never gets cancelled, or a struct that cannot safely serve two callers at once.

My second mistake was swallowing cancellation. I would do the work, get back a context.Canceled error, and treat it like any other failure: log it loudly, increment an error counter, page someone. But a cancelled context usually is not an error in your system. It is the client leaving, or a parent deadline firing. Once I started checking for it explicitly, my error dashboards got a lot calmer.

if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
    // expected: the caller went away or we ran out of time
    return
}

Where it earns its keep

The payoff arrives the day you add a deadline. Say a downstream call has no business taking more than two seconds. You do not need a timer, a channel, and a select written by hand. You derive a child context:

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

resp, err := s.client.Fetch(ctx, url)

Now the deadline propagates. If Fetch is well-behaved and threads ctx into its own HTTP request, the network call itself aborts at two seconds. If the parent context is cancelled first, say the client disconnected after one second, the child is cancelled too, immediately, without waiting out the timeout. Deadlines compose downward, cancellation flows downward, and you got both by passing one argument.

The defer cancel() is not optional decoration, by the way. WithTimeout and WithCancel allocate resources to track the deadline, and cancel releases them. Skip it and go vet will rightly nag you about a context leak.

There is a value-carrying side to contexts too, context.WithValue, and I use it sparingly and with suspicion. It is genuinely useful for request-scoped things that cut across every layer, a request ID for tracing, an auth principal. It is a terrible way to pass ordinary arguments, because it is untyped, invisible in the function signature, and turns "what does this function need" into a treasure hunt. My rule has settled to: if it would make sense as an explicit parameter, make it one. Reserve the value bag for the genuinely ambient stuff that every layer might want and almost none of them will touch.

The honest summary is that I was wrong to read context.Context as boilerplate. It is boilerplate in the sense that it appears everywhere and the compiler makes you write it. It is not boilerplate in the sense of being pointless. Every one of those ctx parameters is a wire down which cancellation and deadlines can travel, and the first time a client disconnects and your whole call tree unwinds cleanly instead of grinding on, you stop minding the typing.