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.
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.Contextin 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 inmain, in tests, and in top-level setup. Inside request handling you should already have a context to derive from. Reaching forBackground()mid-request is almost always a bug: you've just detached this work from cancellation.- A
nilcontext 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.
It's not ceremony. I just hadn't bothered to learn what it was doing.