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

> Pass r.Context() into every downstream call: QueryContext, NewRequestWithContext. If you use context.Background() anywhere in the chain, cancellation breaks. Use WithoutCancel for side effects that must survive.

- Asked: 2026-08-05
- Answered: 2026-08-07
- Asked by: Tolga
- Tags: go, context, concurrency
- Source: https://www.muhammetsafak.com.tr/en/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. 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.

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`. Grep for those calls first; the bug is almost always there.

2. **Start from `r.Context()`.** net/http cancels this context when the client disconnects. Carry it down through `service → repository → driver`. Don't stash the context on a struct; pass it as the first argument to every function (`ctx context.Context`).

3. **DB: use `QueryContext`/`ExecContext`.** With `database/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.

4. **Outbound HTTP: `NewRequestWithContext`.** Build the request with this context so the client respects cancellation and deadlines. Shortcuts like `http.Get`/`http.Post` take no context and never see the cancellation.

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

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

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