# How do I propagate context cancellation correctly through nested service calls?

> Cancellation propagates only if you pass `r.Context()` into every downstream call, leave no `context.Background()` in the chain, and use `WithoutCancel`.

- Asked: 2026-08-05
- Answered: 2026-08-07
- Asked by: Tolga
- Tags: go, concurrency
- Source: https://muhammetsafak.com/just-ask/how-do-i-propagate-context-cancellation-correctly-through-nested-service-calls/
- Language: en-US
- Author: Muhammet Şafak

---
**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?


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](/just-ask/how-do-i-detect-and-prevent-goroutine-leaks-in-a-long/).

## Why

1. **The most common cause: the context got severed.** Somewhere `context.Background()`/`context.TODO()` was used, or a non-Context method was called: `db.Query` instead of `db.QueryContext`, `http.Get` instead of `client.Do` with the context set on `req`. The bug is almost always there.

2. **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 like `http.Get`/`http.Post` take no context. And without a blocking call, cancellation isn't noticed on its own either.

3. **Cancellation may not reach the DB.** With `database/sql` + pgx, the driver's default behavior on context cancellation is to close the underlying connection; actually canceling the query on the Postgres side takes configuring `CancelRequestContextWatcherHandler` yourself — that's not the default. If it doesn't reach the DB, the query keeps spinning for nothing.

## What to do

1. **Start from `r.Context()` and carry it down.** Pass the context through `service → 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:

   ```go
   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)
   ```

2. **Grep the call sites.** Scan for lines with `context.Background()`, `context.TODO()`, `db.Query`, `http.Get`; the break is most likely one of those.

3. **DB: use `QueryContext`/`ExecContext`.** Confirm how your driver handles context cancellation, and configure Postgres-side cancellation if you need it.

4. **Outbound HTTP: `NewRequestWithContext`.** Build the request with this context so the client respects cancellation and deadlines.

5. **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.

6. **Add a timeout alongside cancellation.** Put a `context.WithTimeout` on every external call; that way even if the connection never drops, a hung dependency won't make you wait forever.

7. **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 same `ctx` to 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

- [Concurrency in Go: goroutines and channels in practice](/blog/concurrency-in-go-goroutines-and-channels-in-practice/) — Blog
- [Does my channel buffer size choice hide backpressure problems, and how do I decide it?](https://muhammetsafak.com/just-ask/channel-buffer-size-choice-hide-backpressure-problems-decide/) — Just Ask
- [Should I run several dependent operations with errgroup so the first error cancels the rest?](https://muhammetsafak.com/just-ask/should-i-run-several-dependent-operations-with-errgroup-so-the-first/) — Just Ask
- [How do I detect and prevent goroutine leaks in a long-running Go service?](https://muhammetsafak.com/just-ask/how-do-i-detect-and-prevent-goroutine-leaks-in-a-long/) — Just Ask
