Go's context.WithoutCancel: Keeping Audit Logs Alive After the Request Dies
Here is a bug that only shows up when someone is impatient. A user hits "delete account", the handler deletes the account, and then tries to write an audit row saying who did it. Meanwhile the user's browser has given up and closed the connection. The delete went through. The audit row did not. Nothing was logged at error level because the failure was, technically, expected.
The cause is that r.Context() is cancelled when the client disconnects, and every ExecContext call you pass it to will refuse to run, or abort part-way. The request context is doing its job. You are just asking it to do a job it was never meant for.
The broken version
func (s *Server) deleteAccount(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.DeleteAccount(r.Context(), id); err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
// If the client has already gone, r.Context() is done and this fails.
if err := s.store.WriteAudit(r.Context(), actorFrom(r), "account.delete", id); err != nil {
s.log.ErrorContext(r.Context(), "audit write failed", "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
The window is small per request, but under load with mobile clients it is not that small. Worse, an audit log with gaps that correlate with flaky networks is exactly the sort of thing that gets discovered during an incident review, not before.
What WithoutCancel actually does
Go 1.21 added context.WithoutCancel(parent). It returns a context that keeps all the values of the parent but detaches from its cancellation. Concretely: Done() returns nil, Err() returns nil, Deadline() reports no deadline, and Value() passes straight through to the parent. So your request ID, trace IDs and anything you stashed with context.WithValue survive, which is precisely what you want for a log line that needs to be correlated with the request that caused it.
Note that context.Cause on the result also returns nil. If you rely on the cause chain for diagnostics, it stops at this boundary.
The fix, and the bit people forget
The obvious change is one line, but on its own it is a trap:
// Detached, and with no deadline at all. Don't ship this.
ctx := context.WithoutCancel(r.Context())
You have just removed the only thing that would ever stop that database call. If the audit store hangs, this goroutine hangs, and it holds a connection while it does so. Detaching from cancellation means you now own the timeout. So put one back, with a fresh, deliberate budget:
func (s *Server) audit(parent context.Context, actor, action, target string) error {
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), 5*time.Second)
defer cancel()
return s.store.WriteAudit(ctx, actor, action, target)
}
Order matters here. WithTimeout wraps WithoutCancel, not the other way round. If you wrapped in the opposite order, the timeout would be stripped along with everything else. (Actually, that one is worth a moment: WithoutCancel drops the parent's deadline as well as its cancellation signal, so any deadline the request carried is gone too. That is the point, but it means a request with a 2 second budget can now spend 5 seconds on the audit write. Pick the number knowing that.)
The handler then becomes:
func (s *Server) deleteAccount(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.DeleteAccount(r.Context(), id); err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
if err := s.audit(r.Context(), actorFrom(r), "account.delete", id); err != nil {
s.log.ErrorContext(r.Context(), "audit write failed", "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
Because audit takes the request context and detaches internally, callers cannot forget to do it. That is the design choice I would push: put the detach inside the audit function, not at each call site.
Should the delete and the audit be one transaction?
If the audit row and the deletion live in the same database, the honest answer is often yes. Do both in one transaction and you get "no audit row, no delete" for free, and WithoutCancel is not needed for that path. The detached approach is for the cases where that is not possible: the audit sink is a separate service, a log shipper, or a different store, and the action has already happened by the time you record it.
Even then, be honest about what you have. A detached write with a timeout is best-effort. If the process is killed between the delete and the audit, you still lose the record. For anything where a missing entry is a compliance problem, look at an outbox table written in the same transaction as the action.
Goroutines that outlive the handler
The same detour applies if you kick the audit write into a goroutine so the response is not delayed. The request context is also cancelled when ServeHTTP returns, whether or not the client hung up, so a goroutine that reads r.Context() after the handler exits is racing its own cancellation. Detach first:
ctx := context.WithoutCancel(r.Context())
s.wg.Add(1)
go func() {
defer s.wg.Done()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := s.store.WriteAudit(ctx, actor, action, target); err != nil {
s.log.ErrorContext(ctx, "audit write failed", "err", err)
}
}()
The WaitGroup is not decoration. A detached context will not be told that the server is shutting down, so nothing stops main returning while these goroutines are mid-write. Call s.wg.Wait() after http.Server.Shutdown returns, ideally with its own limit, otherwise you get back the original bug in a rarer form: audit rows lost on every deploy.
Things that do not survive detaching well
- Values that carry lifetime. If someone put a transaction, a request-scoped connection or a tracing span in the context, the value is still there but the thing it points at may be finished. The context is detached; the resources are not.
- Reads that should stop. Only detach the writes you genuinely need to complete. Detaching the whole handler defeats the point of cancellation and lets abandoned requests keep burning CPU.
- Old Go versions. Before 1.21 you wrote a small struct embedding
context.Contextthat overrodeDone,ErrandDeadline. If yourgo.modsays 1.21 or later, delete that helper.
A quick way to convince yourself it works: cancel a context, derive a detached one, and check that ctx.Err() is nil while ctx.Value(key) still returns what you stored. Then add a timeout on top and check that it does fire. The value carrying over and the timeout being yours are the two properties the whole pattern depends on.