"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
- The work must not be lost. A detached goroutine still dies with the process if shutdown is forced (
SIGKILL, a node eviction, a 5s termination grace period). If dropping the write is a correctness or compliance problem, write an outbox row inside the request transaction and let a worker or queue drive it.WithoutCancelis for best-effort work. - The work is on the response path. If the handler's own DB query is being canceled, that's the system working correctly — the client is gone, stop burning the connection. Detaching it just wastes capacity.
- High fan-out. One unbounded goroutine per request is a capacity bomb under a traffic spike. Push to a bounded worker pool or channel; keep
WithoutCancelfor the context you hand the worker. - Long-running work (minutes). That's a job, not a goroutine. Shutdown will never wait long enough for it.
Gotchas
WithoutCancelalso drops the deadline. If you forget theWithTimeout, you've swapped a cancellation bug for a leak. Lint for it.- Some libraries read cancellation out of band.
database/sqlties the connection lifetime to the context you pass toQueryContext; if you pass the request context into something the detached goroutine later uses, cancellation sneaks back in through that value. ritself isn't safe after the handler returns. Readingr.Headeror the body from the goroutine is a data race —net/httpmay recycle the request. Copy the primitives you need before you spawn.- You will now see real errors. Timeouts and dependency failures that were previously masked as "context canceled, probably a disconnect" become visible. That's the point, but expect an alert-volume bump on day one.
- Don't classify all
context.Canceledas client-disconnect noise. Compare witherrors.Is(err, context.DeadlineExceeded)and check a shutdown flag; otherwise a rolling deploy dropping every in-flight background write looks identical to normal churn on a dashboard. errgroup.WithContextre-attaches cancellation. If you build the group from the request context, one client hang-up cancels the whole group. Build it from the detached context.
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.
- Log
errors.Is(err, context.Canceled)alongsidectx.Err()and whether the server is shutting down. The 4% correlating with app backgrounding is client disconnect: the goroutine inheritedr.Context(), whichnet/httpcancels when the client closes the connection orServeHTTPreturns. - 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.