Your Go handler writes an audit record from a goroutine and the rows stopped appearing, with context.Canceled logged at the write. What happened?
answer
- look at where the goroutine's context came from
- the handler's return is a cancellation
- fast requests lose the row most often
- detach the cancellation, keep the values
- then bound it, and question the goroutine
basics
~20 sThe goroutine was given the request context, which the server cancels as soon as the handler returns, so the audit write is abandoned before it starts. Detach it with context.WithoutCancel and give the detached work its own timeout.
solid answer
~50 sThe goroutine inherited `r.Context()`. That context is cancelled the instant `ServeHTTP` returns, so by the time the goroutine reaches its database call the context is already done and `database/sql` fails it immediately with `context.Canceled` — the log line is the symptom, not the cause. The fix is to detach: `ctx := context.WithoutCancel(r.Context())` gives you a context that keeps the request's values, such as a request or trace id, but is never cancelled and has no deadline. Because it is never cancelled, immediately re-bound it with your own `context.WithTimeout`, or the goroutine can outlive the process's patience. Detaching restores the write; it does not make it durable. A goroutine still dies with the process on deploy or crash, and one per request is unbounded, so anything that must not be lost belongs in a bounded worker fed by a queue, or written inside the request instead.
code
go · 10 linesrec := buildRecord(r) // copied out while the handler still owns r
ctx, cancel := context.WithTimeout(
context.WithoutCancel(r.Context()), 5*time.Second)
go func() {
defer cancel()
if err := writeAudit(ctx, rec); err != nil {
log.Println("audit write failed:", err)
}
}()go deeper
Focus on recognising the cause: the goroutine was handed the request's context and the handler returning cancels it. Say that work outliving the response needs a different context.
Explain the mechanics and the fix in code: context.WithoutCancel keeps values but drops cancellation and deadline, so you wrap it in your own timeout and copy the record out of the request before returning.
Go past the fix. Explain why fast requests lose rows more often, that a detached goroutine still dies with the process and is unbounded per request, and what instrumentation would have surfaced the gap before a data engineer did.
Own the pattern: a single helper or worker-pool submission API so nobody writes go f(r.Context()) by hand, plus a stated rule about which categories of work are ever allowed to leave the request path.
## Reading the symptom The log line — `context.Canceled` at the audit write — points at the context, and the context in question is the request's. Confirm it by looking at where the goroutine's `ctx` came from: `go writeAudit(r.Context(), rec)` or a closure capturing `r`. Both give the goroutine a context whose lifetime ended when the handler returned. The timing explains the shape of the data loss. Fast requests lose the record almost always; slow ones occasionally get it in, because the goroutine sometimes reaches the database before the handler unwinds. That is why the failure often looks intermittent and correlates with latency rather than with any property of the record. ## Why it fails at all The server cancels the request context on two events: the client going away, and `ServeHTTP` returning. The second is unconditional — a perfectly successful request cancels its context on the way out. `database/sql` honours contexts, so `ExecContext` on an already-cancelled context does not even reach the driver; it returns straight away. Any well-behaved context-aware call behaves the same. ## The direct fix: detach ```go func handler(w http.ResponseWriter, r *http.Request) { rec := buildRecord(r) // copy everything out of r first w.WriteHeader(http.StatusOK) ctx, cancel := context.WithTimeout( context.WithoutCancel(r.Context()), 5*time.Second) go func() { defer cancel() if err := writeAudit(ctx, rec); err != nil { log.Println("audit write failed:", err) } }() } ``` `context.WithoutCancel(parent)` returns a copy of the parent that is **not** cancelled when the parent is: it has no deadline, its `Err()` stays nil and its `Done()` channel is nil, so it blocks forever. What it keeps is the parent's **values** — the request id, the correlation data, whatever your middleware attached — which is exactly why it is better than starting from `context.Background()`. You detach the cancellation without losing the identity of the request. The second half matters as much as the first: a context that can never be cancelled is a context with no bound at all. Wrap it in `context.WithTimeout` so a wedged dependency cannot pin the goroutine forever, and `defer cancel()` so the timer is released. And note the ordering: build the record *before* returning. The request body and the `ResponseWriter` are invalid once the handler returns, so the goroutine must hold copies, not the request. ## Why the direct fix is not the whole answer Detaching makes the write attempt happen. It does not make it reliable: - **The process can exit.** A deploy, a crash, an OOM kill, or a graceful shutdown that waits for handlers but knows nothing about your goroutine, and the record is gone with no trace. `(*http.Server).Shutdown` waits for active requests; a goroutine you spawned is not one. - **It is unbounded.** One goroutine per request means a slow audit database converts a traffic spike into memory pressure and an ever-growing pile of pending writes. - **Failures are invisible unless you count them.** Add a counter for attempted versus completed writes so the next occurrence shows up as a diverging pair of numbers rather than as a data engineer noticing a gap weeks later. If the records are genuinely required — audit, billing, anything someone reconciles — the structurally right answers are to write them **inside** the request, in the same transaction as the business change where possible, or to hand them to a **bounded worker pool fed by a buffered channel** that graceful shutdown drains, or to persist them to durable storage and let a separate consumer do the slow part. Detaching a context is the correct mechanism for best-effort work that should not be paid for on the response's latency budget; it is not a durability mechanism. ## How to prevent the recurrence Make the mistake hard to write. A tiny helper — one that takes the request context, detaches it, applies the house timeout, and submits to the worker pool — means nobody hand-rolls `go f(r.Context())` again. Grep for `go ` statements inside handlers in review; the pattern is easy to spot once you know it. And exercise the path in a test that asserts the record exists *after* the response has been served, since a test that only checks the HTTP status will pass happily while every row is being dropped.
- What exactly does context.WithoutCancel keep from its parent, and what does it drop?It keeps the parent's values, so a request or trace id attached by middleware still travels. It drops cancellation and deadline entirely: the returned context has no deadline, its `Err()` stays nil and its `Done()` channel is nil, so a select on it blocks forever. That is why you almost always wrap it in your own timeout.
- Why not just use context.Background() for the detached write?It works mechanically but throws away everything the request attached — request id, trace correlation, tenant information — so the resulting log lines and spans are orphaned and the write is much harder to attribute when it fails. WithoutCancel gives the same freedom from cancellation while preserving that identity.
- The detached write now succeeds in testing, but records still go missing during deploys. Why?Because a detached goroutine is not durable. Graceful shutdown waits for in-flight handlers, not for goroutines you spawned, so anything still pending when the process exits is lost. Records that must survive belong in the request's own transaction, or in a queue that shutdown drains before exiting.
- How would you make this failure visible next time rather than waiting for someone to notice a gap?Count attempts and completions separately and alert on the divergence, and log the write error rather than discarding it. A test that asserts the record exists after the response is served also catches it, whereas a test asserting only the status code passes while every row is dropped.
saying these in an interview costs you the question
- Blames the database or the driver for the cancellation
- Passes r.Context() to work meant to outlive the response
- Uses context.WithoutCancel and never adds a timeout
- Switches to context.Background and loses request correlation
- Calls a detached goroutine durable enough for audit data
- Spawns one unbounded goroutine per request for background writes