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.
Short answer
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. I covered another face of the same discipline — work that ignores ctx piling up in the background — in the goroutine leaks record.
Why
-
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. The bug is almost always there. -
Cancellation is cooperative; the shortcuts never see it. net/http cancels
r.Context()when the client disconnects — but for that signal to have any effect, the call has to observe it. Shortcuts likehttp.Get/http.Posttake no context. And without a blocking call, cancellation isn’t noticed on its own either. -
Cancellation may not reach the DB. With
database/sql+ pgx, the driver’s default behavior on context cancellation is to set a deadline on the connection and close it; actually canceling the query on the Postgres side takes configuringCancelRequestContextWatcherHandleryourself — that’s not the default. If it doesn’t reach the DB, the query keeps spinning for nothing.
What to do
-
Start from
r.Context()and carry it down. Pass the context throughservice → repository → driver. Don’t stash it on a struct; pass it as the first argument to every function (ctx context.Context). 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) -
Grep the call sites. Scan for lines with
context.Background(),context.TODO(),db.Query,http.Get; the break is most likely one of those. -
DB: use
QueryContext/ExecContext. Confirm how your driver handles context cancellation, and configure Postgres-side cancellation if you need it. -
Outbound HTTP:
NewRequestWithContext. Build the request with this context so the client respects cancellation and deadlines. -
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. 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.
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.