Golang
Go Context: why you pass it down the chain and why every context needs a timeout
context.Context isn't magic, and it isn't the first argument out of habit. It's the mechanism that lets the entire call chain know when to stop. Without it, every cancelled request becomes a leak.
What happens without context
An HTTP handler accepts a request, calls a service, the service hits a repository, the repository talks to the database. The client closes the connection and walks away.
Without ctx: the DB query keeps running, holds a pooled connection, burns CPU. Goroutines hang. Every dropped client adds to the pile.
With ctx: r.Context() signals cancellation, each layer passes it deeper, and a sane DB driver tells the server to cancel the query. Resources free up immediately.
How "cancelling the whole chain" actually works
This is the part people gloss over. There is no broadcast, no kill signal flying around. The mechanism is much simpler.
Context is a tree. Every call to WithCancel, WithTimeout, or WithDeadline creates a child context bound to its parent. Each context exposes a Done() channel that gets closed on cancellation.
Two rules run the whole show:
- When a parent's
Done()closes, every child'sDone()closes too, recursively down the tree. - Any function that accepts a
ctxis responsible for listening toctx.Done()and returningctx.Err()when it fires.
That's it. Cancellation isn't pushed from the top. It's a subscription from the bottom. The root just closes a channel, and every level decides for itself how to react (usually: bail out with an error).
func Handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // ctx-1: HTTP server cancels this if the client leaves
ctx, cancel := context.WithTimeout(ctx, 2*time.Second) // ctx-2: child of ctx-1
defer cancel()
user, err := service.GetUser(ctx, id) // ctx-2 goes deeper
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.Find(ctx, id) // same ctx, even deeper
}
func (r *Repo) Find(ctx context.Context, id string) (*User, error) {
// QueryRowContext listens to ctx.Done() and tells the DB to cancel
return r.db.QueryRowContext(ctx, "SELECT ...", id).Scan(...)
}
What happens when the client disconnects:
- HTTP server closes
ctx-1.Done() ctx-2is its child, so itsDone()closes automaticallyQueryRowContextis watching that sameDone()and sends a cancel to the DBFindreturns an error,GetUserreturns an error,Handlerreturns an error- The whole chain unwinds in milliseconds
Who actually does the listening
The standard library and decent drivers handle it for you:
| Function | How it watches ctx |
|---|---|
http.Client.Do |
aborts read/write when Done() fires |
sql.DB.QueryContext |
sends cancel to the driver, driver to the DB |
time.After vs ctx.Done() in select |
first one to fire wins |
io.Copy |
does not listen; wrap it yourself if needed |
For functions you write yourself, you have to wire it up by hand:
// Option 1: select when there is a blocking operation
select {
case <-ctx.Done():
return ctx.Err()
case res := <-someChan:
return res, nil
}
// Option 2: periodic check in a long loop
for _, item := range bigSlice {
if err := ctx.Err(); err != nil {
return err
}
process(item)
}
Rules for passing ctx
ctxis always the first parameter of a function- never store
ctxin a struct, pass it explicitly - never pass
nil. If you really don't know what to put there, usecontext.TODO() ctxis a value, copying is cheap- don't wrap an existing ctx without a reason. Each
WithCancelspawns a watchdog goroutine
Why every context needs a timeout
Say everything is wired up properly: ctx flows all the way to the database, cancellation works. But the client doesn't go anywhere, the database itself just freezes. What now?
You wait. For a long time. Until TCP keepalive gives up or someone kills the process.
That's why every responsibility boundary gets a WithTimeout:
ctx, cancel := context.WithTimeout(parentCtx, 500*time.Millisecond)
defer cancel()
result, err := repo.Find(ctx, id)
What this buys you:
- Protection from slow dependencies. DB is stuck, you don't hang forever, you bail at 500ms with a clear error.
- Resource release.
defer cancel()makes sure the watchdog goroutine doesn't leak even if the call returns before the deadline. - Cascading cancellation in both directions. Parent ctx gets cancelled earlier? Your timer cancels too. Your timer fires? All child contexts cancel.
- Backpressure. Under load, short timeouts free the connection pool faster than a queue of stuck requests ever could.
- SLA in code. You see
WithTimeout(2*time.Second)and you immediately know what the call is budgeted for. No digging through Confluence.
What timeouts to pick
| Level | Ballpark | Reasoning |
|---|---|---|
| HTTP handler (overall) | 5-10s | clients won't wait longer anyway |
| External API | 1-3s | plus retry with backoff |
| DB query | 200ms-1s | longer means something's wrong with the query |
| Redis / cache | 50-100ms | if cache is slow, fall through to source |
| Inter-service RPC | smaller than parent | minus network overhead |
Core rule: a child timeout must always be smaller than its parent. Otherwise it's useless, the parent fires first.
What not to do
- Don't ignore
cancel().go vetcomplains for a reason, it's a leak. - Don't dump everything into
ctx.WithValueis for request-scoped data (trace id, user id), not for DI or config. - Don't wrap
WithTimeout(ctx, 30*time.Second)at every level "just in case". If the parent already has a 1s timeout, your 30s is noise. - Don't check
ctx.Err()on every line with anif. If there's a blocking call that acceptsctxinside, it will bail on its own. - Don't piggyback "fire and forget" work on the request ctx. If you need background work to outlive the response, start a fresh ctx from
context.Background()with its own timeout. Do not inherit from the request.
TL;DR
Context is about two things: cancellation and deadlines. Pass it through every call, set timeouts at every responsibility boundary, always defer cancel(). Cancellation alone only protects you from a client that left. Timeouts protect you from a dependency that froze. You need both.
Sources
- pkg.go.dev/context: package docs with the canonical rules (ctx as first param, no nil, no storing in structs,
WithValueonly for request-scoped data). - go.dev/blog/context: the original Go blog post introducing the package, with a full worked HTTP server example.
- go.dev/blog/context-and-structs: focused follow-up on why
ctxshould be a function parameter and not a struct field.