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.

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

  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 set a deadline on the connection and close it; 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:

    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

Expertise: Go Developer
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