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

stop fighting context.Context and start threading it

How I stopped treating context.Context as ceremony and started using it properly for cancellation, deadlines, and request-scoped values in Go services.

A screen of Go source code with function signatures

For a long time I treated context.Context as a tax. Every function signature grew a ctx context.Context as the first parameter, I passed it down dutifully, and I never actually used it for anything. It was cargo cult: I'd seen it in the standard library, so I copied the shape and ignored the substance. Then a service started leaking goroutines under load and I had to actually understand what the thing was for. This is the post I wish I'd read first.

the point is cancellation

The single most useful thing context does is propagate cancellation. When a request comes in over HTTP, gets handed to a handler, which calls a service, which calls a database and an upstream API, you have a tree of work all hanging off that one request. If the client hangs up, or you blow a deadline, you want all of that work to stop. Not eventually. Now.

Without context, the database call and the upstream call keep running even though nobody is waiting for the answer. Under load, that is how you end up with thousands of goroutines all chewing on work for clients who left minutes ago. The CPU graph climbs, the connection pool drains, and the whole thing tips over.

The fix is that every blocking call takes a context, and when the context is cancelled, the call returns early with context.Canceled. The standard library plumbs this all the way down: net/http, database/sql, the net dialer. You just have to actually pass the request's context rather than reaching for context.Background() out of habit.

func (s *Service) Lookup(ctx context.Context, id string) (*Record, error) {
    row := s.db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = $1", id)
    var r Record
    if err := row.Scan(&r.Name, &r.Email); err != nil {
        return nil, fmt.Errorf("lookup %s: %w", id, err)
    }
    return &r, nil
}

The only difference from the version I'd have written two years ago is QueryRowContext instead of QueryRow, and the ctx it threads in. That one change means a cancelled request stops waiting on the database instead of holding the connection until the query finishes for an audience of nobody.

deadlines are cancellation with a timer

Once you've internalised cancellation, deadlines are free. A deadline is just a context that cancels itself at a particular time. You almost never want a call to an upstream service to be able to take forever, so you wrap it:

func (s *Service) FetchUpstream(ctx context.Context, q string) (*Result, error) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url+q, nil)
    if err != nil {
        return nil, err
    }
    resp, err := s.client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("upstream: %w", err)
    }
    defer resp.Body.Close()
    // ...
}

Two things took me an embarrassingly long time to learn here.

First: always call cancel, even when the work finishes well before the deadline. WithTimeout returns a cancel function, and if you don't call it you leak the timer and its goroutine until the deadline fires. defer cancel() on the next line is the habit to build. go vet will now shout at you if you forget, which is one of the genuinely good things to land in the toolchain.

Second: the timeout you set wraps the parent context. If the parent already has 500ms left and you ask for 2 seconds, you get 500ms, because the parent's deadline still applies. That's correct behaviour, it just surprised me the first time I watched a call die early. Context deadlines compose: the tightest one in the chain wins.

A diagram of nested boxes representing a context tree with timeouts

the rules I now actually follow

A handful of conventions, learned mostly by breaking them:

  • Context is the first parameter and it's named ctx. Not stored in a struct, not passed second, not optional. The whole point is that it's visible and uniform.
  • Never store a context.Context in a struct field. It's request-scoped; structs usually aren't. If you find yourself wanting to, you probably want to pass it to the method instead.
  • context.Background() belongs in main, in tests, and in top-level setup. Inside request handling you should already have a context to derive from. Reaching for Background() mid-request is almost always a bug: you've just detached this work from cancellation.
  • A nil context is never correct. If you genuinely have nothing better, context.TODO() at least documents that you know it's a placeholder.

the values trap

Context can also carry request-scoped values via context.WithValue. This is the part people misuse, including past me. It is tempting to use it as a general-purpose bag to avoid threading parameters through functions. Don't. It's untyped, it's invisible at the call site, and it turns your function signatures into liars.

Where it earns its place is genuinely cross-cutting, request-scoped data that every layer might want but no layer wants in its signature: a request ID for log correlation, an authenticated user, a trace span. The standard advice is to use an unexported key type so nobody can collide with you:

type ctxKey int

const requestIDKey ctxKey = iota

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

func RequestID(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(requestIDKey).(string)
    return id, ok
}

If you can pass it as an explicit parameter instead, do that. Values are for the handful of things that genuinely need to ride along invisibly, and nothing else.

what changed

The goroutine leak that started all this turned out to be exactly the thing context exists to prevent: a background fetch that used context.Background() instead of the request's context, so it kept running after every client disconnect. One line changed, from Background() to the ctx that was sitting right there, and the leak vanished off the graph.

The wider shift was mental. Context stopped being a parameter I copied because the linter and the standard library wanted it, and became the mechanism I reach for whenever I want work to be able to stop. Cancellation, deadlines, and a small amount of request-scoped metadata. Thread it through honestly, pass the real one rather than a fresh Background(), and most of the lifecycle problems in a Go service quietly stop happening.

A simplified request flow with cancellation propagating back up

It's not ceremony. I just hadn't bothered to learn what it was doing.