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

the context i kept ignoring until it bit me

How I went from treating Go's context.Context as boilerplate to understanding what it's actually for, and why threading it through your call stack is the point rather than the chore.

A code editor with Go source on screen

For a long time context.Context was, to me, the thing you put first in every function signature because the linter expected it and the standard library functions wanted one. I'd accept it, ignore it, and pass context.Background() whenever a real one was inconvenient. It worked, in the sense that the code compiled and ran. It also meant that when a request hung, it hung all the way down, holding a database connection and a goroutine hostage, because nothing in the chain had any idea it was supposed to give up. That's the story of how I stopped treating context as decoration.

what it's actually for

The clearest way I can put it: a Context is how a request says "stop" to everything working on its behalf. It carries a cancellation signal, an optional deadline, and a small bag of request-scoped values, and it's designed to be passed down a call tree so that cancelling the top cancels the lot.

That's the whole idea, and it's easy to miss because the cancellation half is invisible until something goes wrong. On a healthy day, requests complete before any deadline fires, so the context does nothing visible and you can cargo-cult it for months without learning what it's for. Then one upstream dependency gets slow, your handlers pile up waiting on it, your connection pool drains, and the question becomes: when the client gave up and walked away thirty seconds ago, why is my server still doing the work?

The answer is that the work never heard about the client leaving. The signal existed. It was sitting in the Context the whole time. Nobody was listening for it.

threading it through is the job

The mistake I made was thinking the context parameter was the overhead and the actual function was the work. It's the other way round. The point of accepting a ctx is that you pass it onward, to every function that might block, so that cancellation propagates. Break the chain anywhere, and everything below that point becomes uncancellable.

So this is the wrong shape, and I wrote a lot of it:

func (s *Service) Handle(ctx context.Context, id string) (*Result, error) {
    row := s.db.QueryRow("SELECT ... WHERE id = $1", id) // ctx dropped on the floor
    // ...
}

QueryRow has no idea a request is in flight. If the client disconnects, this query runs to completion regardless, and so does everything it triggers. The fix is unglamorous and it is the entire discipline:

func (s *Service) Handle(ctx context.Context, id string) (*Result, error) {
    row := s.db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
    // ...
}

QueryRowContext instead of QueryRow. http.NewRequestWithContext instead of building a bare request. Pass ctx into the helper, and the helper passes it into its helper, all the way to the bottom where something actually does I/O. The database/sql, net/http and most well-behaved client libraries all have context-aware variants now, and the rule is simply to prefer them every single time. It feels like noise while you're typing it. It is the load-bearing part.

A diagram of a request fanning out through a call stack

deadlines, and the moment it clicked

What finally made it real for me was deadlines. A context can carry a deadline, and any context-aware call that's still in flight when the deadline passes returns an error instead of hanging forever.

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

resp, err := s.client.Do(req.WithContext(ctx))
if err != nil {
    // this now fires after 2s instead of whenever the upstream felt like it
    return fmt.Errorf("calling upstream: %w", err)
}

The first time I added a WithTimeout to a flaky upstream call and watched the goroutine count stop climbing during an incident, the whole thing stopped being abstract. The deadline propagated down the chain because I'd done the boring work of threading the context through. The slow upstream got two seconds, then every context-aware operation underneath it gave up together, the connections went back to the pool, and the server stayed alive. None of that would have worked if I'd dropped ctx anywhere along the way.

That defer cancel() matters too, and it's the bit people skip. WithTimeout and WithCancel return a cancel function, and you must call it, even on the happy path. It releases the resources associated with the context and stops a timer goroutine. Forget it and go vet will mutter at you, rightly. I treat cancel like Close: the moment I create one, the defer goes on the next line before I write anything else.

a few rules I actually keep now

The values bag is the part I use least and trust least. context.WithValue is genuinely useful for request-scoped data that crosses API boundaries: a request ID, an auth token, a trace span. It is a terrible way to pass ordinary function arguments, because you lose the type checking and you hide a dependency that should be explicit in the signature. My rule is that if a function genuinely needs a thing to do its job, that thing goes in the parameter list where the compiler can see it. The context is for the request's metadata and its cancellation, not a sneaky way to avoid plumbing.

The other rule, the one the standard library documents and I now believe: don't store a Context in a struct. Pass it explicitly, as the first argument, to each function that needs it. I fought this because stashing it on a struct felt tidier. It isn't. A context describes the lifetime of a single call, and the moment you cache one on a long-lived object you've tied a request's lifetime to something that outlives the request, which is exactly the bug you were trying to avoid.

And context.Background() is fine, but it's the root. It belongs in main, in init, in tests, at the very top of the tree where a request begins. Reaching for it halfway down because the real context is awkward to get hold of is the smell that you've broken the chain somewhere above and are papering over it. When I catch myself typing context.Background() inside a request handler, that's the signal to go back up and find where I dropped the real one.

what changed

None of this is clever. There's no trick to it. The shift was entirely in how I think about the parameter: not as a tax the API charges me, but as a live wire running the length of my call stack that lets the top end pull the plug on everything below. Threading it through carefully is the feature. The day I started treating a dropped ctx as a bug rather than a tidy-up-later, my services got noticeably better at dying gracefully, which on a bad night is the kindest thing a service can do.

A second view of code on a quiet screen

It's Christmas Day as I write this, which is a daft time to be thinking about cancellation semantics, but the quiet is good for it. If you've been passing context.Background() around like I was, the homework is small: walk one request path from handler to database, and at every call ask "does this take a context, and am I giving it the real one?" You'll find a few places where you weren't. Fix those, and you've done the whole job.