For years I treated context.Context as the thing you put first in a function signature because the linter sulks otherwise. You take it, you ignore it, you pass context.Background() somewhere deep down because you couldn't be bothered to thread the real one through. It compiles. The tests pass. Everyone moves on.
Then we had a deploy where a single slow downstream call held a request open for ninety seconds, and the only reason it eventually died was that the load balancer gave up, not us. The goroutine kept running. The database connection stayed checked out. Multiply that by a few thousand in-flight requests and you have a service that's technically up and entirely useless.
The fix was not clever. It was just doing the boring thing everywhere instead of nowhere.
The actual rule
A context represents the lifetime and cancellation of one unit of work, usually a request. The job is to carry it from the edge of your program all the way to the leaf that does I/O, and to actually respect it when it's done. That means no context.Background() once you're past main and your handlers. It means the context goes into the database call, the HTTP client, the queue publish, all of it.
func (s *Service) Lookup(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
row := s.db.QueryRowContext(ctx, "select name from users where id = $1", id)
var u User
if err := row.Scan(&u.Name); err != nil {
return nil, fmt.Errorf("lookup %s: %w", id, err)
}
return &u, nil
}
QueryRowContext, not QueryRow. That one suffix is the difference between a cancelled request that cleans itself up and a goroutine quietly holding a connection until the heat death of the pool.
The bits that bit me
Two things tripped me up while doing this properly.
The first was context.WithValue. It's tempting to shove request IDs, user objects, the kitchen sink into the context and pull them out three layers down. Resist most of it. Values in a context are untyped and invisible at the call site, so they turn into a sort of dynamic scope you can't see. I keep it to request-scoped metadata that genuinely every layer might want, the trace ID, and not much else. Business data goes in explicit arguments where the compiler can see it.
The second was background work kicked off from a request. If a handler spawns a goroutine to do something after responding, and you pass it the request context, that context gets cancelled the moment the handler returns. Your background job dies instantly and you spend an afternoon confused. The answer is to derive a fresh context for genuinely detached work, ideally with its own sensible timeout, rather than reaching for the request's one out of habit.
None of this is hard. It's just discipline, applied at every boundary rather than the convenient ones. Since I started actually threading it through, the ninety-second zombie request hasn't come back, and defer cancel() has become muscle memory. Worth the dull afternoon it took to retrofit.