Skip to content
Muhammet Şafak
tr
Asked by: Tolga Answered:

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.

  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:

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

Tags: #go#context#concurrency
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

More Questions

All questions

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind