For about a year I treated context.Context as a thing other people's code needed and mine didn't. The standard library wanted one, so I'd hand it context.Background() at the call site, look away, and get on with my life. Then we had an incident where a downstream dependency got slow, and our service didn't get slow, it got dead. Goroutines piled up waiting on a request that would never return, memory climbed, and the only fix was a restart. That was the afternoon I stopped treating context as boilerplate and started treating it as the plumbing it actually is.
what was actually wrong
The service was a fairly ordinary HTTP API. A request came in, we called two or three internal services and a database, assembled a response, and sent it back. The problem was that every one of those outbound calls had no notion of giving up. If the database hung, the handler hung. If the handler hung, the goroutine serving that request hung, holding its stack and whatever it had allocated. Under normal load you never notice. Under a slow dependency you accumulate stuck goroutines until something falls over.
The fix is the thing context.Context exists for: a value that carries a deadline and a cancellation signal down through your call tree, so that when the request at the top is abandoned, everything underneath it can find out and stop.
the rule that made it click
The thing that unstuck me was a single rule, which is in the docs but didn't land until I'd been bitten: context is the first argument, it's named ctx, and you pass it down, you don't store it. Every function that does I/O or might block takes a ctx context.Context as its first parameter and passes it on to whatever it calls. You never put it in a struct field. You never start a Background() halfway down the tree, because that severs the chain and the cancellation never reaches the bottom.
func (s *Server) handleOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
order, err := s.fetchOrder(ctx, orderID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// ...
}
func (s *Server) fetchOrder(ctx context.Context, id string) (*Order, error) {
return s.db.QueryOrder(ctx, id)
}
r.Context() is the part I'd been missing. The HTTP server already creates a context for each request and cancels it when the client disconnects or the handler returns. All I had to do was take it and thread it through. The plumbing was already laid; I just hadn't connected my pipes to it.
deadlines, where the real win lives
Cancellation on client disconnect is nice, but the bigger win was deadlines. A downstream that hangs forever is the worst case. So at the top of the handler, or sometimes per-call, I set a budget:
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
order, err := s.fetchOrder(ctx, orderID)
if err != nil {
// ctx.Err() will be context.DeadlineExceeded if we ran out of time
}
That defer cancel() is not optional, and it caught me out early. If you create a context with a timeout and never call its cancel function, you leak the timer and the goroutine watching it. go vet will warn you about it, which I now trust more than I trust myself. The pattern is mechanical: create, defer cancel, use. Do it the same way every time and it stops being a thing you think about.
The effect on the original incident is the whole point. With a two-second deadline threaded all the way down to the database driver (and the standard database/sql honours context cancellation on query, which I hadn't realised until I went looking), a slow dependency now produces a flood of timeout errors instead of a flood of stuck goroutines. Errors I can see, alert on, and reason about. Stuck goroutines just quietly eat the process.
the unglamorous part
Most of the actual work was tedious rather than clever. It was going through every function in the request path and adding ctx context.Context as the first argument, then chasing the compile errors outward until the whole tree was wired up. A few hundred lines touched, almost none of them interesting. The temptation, several times, was to cheat: just call context.Background() here to make the signature happy and move on. That's the one thing you mustn't do, because a Background() in the middle of the tree is a wall the cancellation can't cross, and you've quietly recreated the bug you were fixing.
A couple of smaller lessons fell out of it. Don't smuggle optional parameters through context.WithValue; it's tempting and it's a trap, because it makes the data invisible to the type system and to anyone reading the signature. Keep context for what it's for: deadlines, cancellation, and request-scoped values that genuinely span the whole tree like a trace ID. Everything else is a function argument.
The mental shift, in the end, was small. Context isn't an annoyance the standard library imposes on you. It's a single channel for "the thing that wanted this work no longer wants it," running the full height of your call stack. Once I'd threaded it through properly, a class of outage I used to fix by restarting became a class of error I could just handle. That's a good trade for an afternoon of boring edits.