How do I propagate context cancellation correctly through nested service calls?
Question
In the HTTP handlers I write in Go, I want a client disconnect to also cancel the downstream Postgres query and the outbound HTTP call. But in practice my handlers keep running even after the request is long gone; the query completes, the external API gets called. I've layered the chain as `handler → service → repository → driver`. How do I propagate context cancellation correctly through these layers, and where might I be going wrong?
Answer
Short answer: cancellation only propagates if you actually pass the context down. You have to start from r.Context() and hand that context to every downstream call (db.QueryContext, http.NewRequestWithContext); if you use context.Background() anywhere in the chain, or drop the context, the work keeps running.
-
The most common cause: the context got severed. Somewhere
context.Background()/context.TODO()was used, or a non-Context method was called:db.Queryinstead ofdb.QueryContext,http.Getinstead ofclient.Dowith the context set onreq. Grep for those calls first; the bug is almost always there. -
Start from
r.Context(). net/http cancels this context when the client disconnects. Carry it down throughservice → repository → driver. Don’t stash the context on a struct; pass it as the first argument to every function (ctx context.Context). -
DB: use
QueryContext/ExecContext. Withdatabase/sql+ pgx, when the context is cancelled the driver actually sends a cancel request to Postgres and aborts the running query. Confirm your driver honors it — pgx does; if cancellation doesn’t reach the DB, the query keeps spinning for nothing. -
Outbound HTTP:
NewRequestWithContext. Build the request with this context so the client respects cancellation and deadlines. Shortcuts likehttp.Get/http.Posttake no context and never see the cancellation. -
Don’t over-cancel — separate what must survive. If a side effect must complete independently of the request’s lifetime (writing to an outbox, emitting an event), derive a new context:
context.WithoutCancel(ctx)on Go 1.21+, or a fresh context with its own timeout. Otherwise the client disconnect kills your side effects too. -
Add a timeout alongside cancellation. Put a
context.WithTimeouton every external call; that way even if the connection never drops, a hung dependency won’t make you wait forever. -
Your own goroutines and loops must observe ctx too. Without a blocking call, cancellation isn’t noticed on its own; in long-running loops add an exit check with
select { case <-ctx.Done(): return ctx.Err() ... }. Pass the samectxto any goroutine you spawn inside the handler, or they keep running in the background even after the request is gone.
The skeleton looks like this:
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // cancelled when the client disconnects
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := h.svc.Do(ctx, id); err != nil {
if errors.Is(err, context.Canceled) {
return // client is gone, exit quietly
}
http.Error(w, "internal", http.StatusInternalServerError)
}
}
// repository:
row := db.QueryRowContext(ctx, "SELECT ...", id)
// outbound call:
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
Bottom line: personally I’d audit every call site: r.Context() in, *Context methods at every layer, and WithoutCancel for the few side effects that must survive a disconnect. Once you’ve settled that discipline, the moment the client disconnects the Postgres query and the outbound call fall away on their own — and the wasted CPU and connections go with them.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.