"context canceled" after the HTTP handler returns: detach the request context correctly

Go · Intermediate · 6 min read · published

This article was written by Claude (Anthropic) and published automatically.

What this solves: Fire-and-forget work started inside a Go handler dies with "context canceled" when the client disconnects. Here's how to detach it without losing trace IDs or leaking goroutines.

The Problem

You get a context canceled error after the HTTP handler returns, from code that has nothing to do with the response. A handler answers 200 in 30ms, then a goroutine writes an audit row:

level=error msg="audit insert failed" err="context canceled" trace=8f21c0

It happens on about 3% of requests — and the 3% is not random. It tracks exactly with clients that time out, users who hit Stop, and mobile apps that get backgrounded mid-request. Under load testing with a well-behaved client, the error rate is zero, which is why it shipped.

The same shape shows up as rpc error: code = Canceled, pq: canceling statement due to user request, or an OpenTelemetry span that ends with status Cancelled and no children.

Why the Obvious Fix Falls Short

The reflex is to stop passing r.Context() into the goroutine and pass context.Background() instead. It makes the error disappear, and it creates three quieter problems.

You lose everything attached to the request. Trace IDs, tenant IDs, the authenticated user, the request-scoped logger — all of those live as context values. context.Background() is empty, so your audit write suddenly appears in a trace of its own, unlinked from the request that caused it. Debugging the next incident gets materially harder.

You lose the deadline. A hung Postgres connection or a provider that stops responding now has an unbounded goroutine attached to it. One bad dependency and goroutine count climbs until the pod OOMs.

You become invisible to graceful shutdown. http.Server.Shutdown waits for in-flight handlers. A detached goroutine is not in-flight anything. SIGTERM arrives, the process exits, and your audit write vanishes with no error anywhere — a strictly worse failure than the one you started with.

The other common attempt — ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) — doesn't help at all. A child context is canceled when either the timeout fires or the parent is canceled. The client disconnect still kills it.

How It Actually Works

net/http creates a context per request and cancels it when any of these happen: the client closes the connection, the HTTP/2 stream is reset, ServeHTTP returns, or the server begins shutdown. Cancellation flows down the tree only. Values also flow down. The two are bundled into the same object, which is why detaching one normally means losing the other.

context.WithoutCancel (Go 1.21+) is the scalpel: it returns a context that keeps the parent's values but is not a cancellation child of it. You then attach your own deadline.

flowchart TD
    A[Client disconnects] --> B[net/http cancels r.Context]
    B --> C[DB query in handler<br/>aborts — correct]
    B -.->|cancel signal| D["WithoutCancel(r.Context())"]
    D -.->|signal stops here| E[cut]
    D ==>|values: trace, tenant, user| F[Detached ctx]
    F --> G["WithTimeout(15s)"]
    G --> H[Audit write goroutine]
    I[SIGTERM] --> J[srv.Shutdown drains handlers]
    J --> K[wg.Wait drains detached goroutines]
    K --> H
    style E fill:#fee,stroke:#c00

So cancellation has two independent sources: the request (which should not reach background work) and process shutdown (which should). WithoutCancel handles the first; a sync.WaitGroup the shutdown path waits on handles the second.

Before and After

// BEFORE: goroutine inherits the request's cancellation
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    if err := h.store.Delete(r.Context(), id); err != nil {
        http.Error(w, "nope", 500)
        return
    }

    go func() {
        // r.Context() is already canceled by the time this often runs:
        // ServeHTTP returned, or the client hung up.
        if err := h.audit.Record(r.Context(), id); err != nil {
            log.Error("audit insert failed", "err", err) // context canceled
        }
    }()

    w.WriteHeader(http.StatusNoContent)
}
// AFTER: values preserved, cancellation detached, bounded, drainable
type Handler struct {
    store Store
    audit Audit
    bg    sync.WaitGroup // Server waits on this during shutdown
}

func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    if err := h.store.Delete(r.Context(), id); err != nil {
        http.Error(w, "nope", 500)
        return
    }

    // Keeps trace ID / tenant / logger; drops the cancel wire.
    bgCtx := context.WithoutCancel(r.Context())
    bgCtx, cancel := context.WithTimeout(bgCtx, 15*time.Second) // own bound

    h.bg.Add(1)
    go func() {
        defer h.bg.Done()
        defer cancel()
        if err := h.audit.Record(bgCtx, id); err != nil {
            log.Error("audit insert failed", "err", err) // now a real error
        }
    }()

    w.WriteHeader(http.StatusNoContent)
}

// main: drain handlers, then drain detached work
_ = srv.Shutdown(shutdownCtx)
done := make(chan struct{})
go func() { h.bg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(10 * time.Second):
    log.Warn("background work abandoned at shutdown")
}

Pre-1.21, the equivalent is a small type detached struct{ context.Context } that overrides Done, Err, and Deadline to return zero values while delegating Value.

When NOT to Use This

Gotchas

Key takeaway: Use context.WithoutCancel plus your own timeout for work that must outlive the request — it keeps trace/tenant values while cutting the cancellation wire, and register the goroutine with shutdown so it still gets drained.

Real-world challenge

After a mobile release, your Go API logs a burst of `context canceled` errors from a Stripe refund call. The refund handler responds 202 immediately and finishes the provider call in a goroutine. Ops reports roughly 4% of refunds never reach Stripe, and it correlates with users backgrounding the app. Separately, during rolling deploys you now see a smaller set of the same errors. Diagnose and fix both.

Diagnosis. Two distinct cancellations share one error string.

  1. Log errors.Is(err, context.Canceled) alongside ctx.Err() and whether the server is shutting down. The 4% correlating with app backgrounding is client disconnect: the goroutine inherited r.Context(), which net/http cancels when the client closes the connection or ServeHTTP returns.
  2. The deploy-time errors are your base/shutdown context being canceled — real, and you do want to know about them, but you want a drain, not an abort.

Fix.

work := context.WithoutCancel(r.Context())     // keep trace/tenant, drop client cancel
work, cancel := context.WithTimeout(work, 15*time.Second)

refundWG.Add(1)                                 // shutdown waits on this
go func() {
    defer refundWG.Done()
    defer cancel()
    if err := stripe.Refund(work, id); err != nil {
        outbox.Enqueue(id)                      // durable retry, not a dropped refund
    }
}()

Then in shutdown: srv.Shutdown(ctx) followed by refundWG.Wait() with its own deadline.

The deeper fix: money-moving work shouldn't live in an in-process goroutine at all. Write an outbox row in the same transaction as the refund request and let a worker drive it; the goroutine becomes a latency optimization, not the only attempt.