Go WithoutCancel Kept the Values. Where Did the Deadline Go?

Go’s WithoutCancel keeps request values but drops the deadline. Check what the downstream call sees and give detached work its own budget.

Share
Two stacks of orange cards, one beside a clock and the other beside an empty clock-shaped recess.

The trace ID made it into your Go cleanup call. The deadline didn't. That's what context.WithoutCancel does: it keeps value lookups connected to the parent, but strips the parent's cancellation and deadline. If the call should survive the HTTP handler and still have a time limit, give it a fresh deadline with context.WithTimeout(context.WithoutCancel(r.Context()), budget). If it should stop when the handler's context ends, derive from r.Context() directly instead.

This is an AI-prepared, documentation-based explainer, not a personally reproduced incident. The scenario and snippets are illustrative. See the editorial policy.

A context carries more than one thing

Suppose a handler needs to tell an upstream service to release a temporary reservation. It's reasonable to want the cleanup call to survive a client disconnect. The original context isn't a great fit for that job: for incoming server requests, Go's HTTP documentation says the request context is cancelled when the client connection closes, the request is cancelled with HTTP/2, or ServeHTTP returns. But removing that connection also removes the old deadline. The trace ID can look perfectly healthy while the clock has quietly left the building.

The WithoutCancel contract is specific: no deadline, no error, a nil Done() channel and no cancellation cause. Values can still be resolved through the parent, as the standard-library implementation shows. This isn't a copy of the request context with one flag flipped. It changes which signals a downstream operation can observe.

Check the context at the call site

At the boundary where you start the cleanup, inspect the context that actually reaches the client. Check the boolean from Deadline() and whether Done() is nil. Don't infer either from a trace ID, and don't dump context values into logs. A nil channel is an important clue: the Go specification says a case on a nil channel can't proceed. In a select with other cases, that cancellation case is disabled. A bare receive from that channel can wait forever.

A separate HTTP client timeout may still bound the call. Check it before diagnosing a hang, but remember that the context's contract remains the same even if another timer saves you. In the illustrative helper below, cleanup must pass the context to the actual operation, such as an outgoing request built with http.NewRequestWithContext. A timer on a context can't stop code that ignores it.

func releaseAfterRequest(
    requestCtx context.Context,
    cleanup func(context.Context) error,
) error {
    base := context.WithoutCancel(requestCtx)
    ctx, cancel := context.WithTimeout(base, 2*time.Second)
    defer cancel()
    return cleanup(ctx)
}

The two seconds are an example cleanup budget, not a recommendation for your service. WithTimeout sets a new deadline on the detached context. It doesn't restore the old deadline. If policy requires respecting the original absolute deadline even after detaching cancellation, read that deadline from the parent and explicitly choose the earlier of it and the cleanup budget. An already expired deadline should stay expired under that policy.

Put the budget where the work is owned

This helper runs synchronously. If you move the operation into a goroutine, make the goroutine or its supervisor own the new context and its cancel function. Creating the timeout in the handler and deferring cancellation there can cancel the cleanup as soon as the handler returns. For work that has to survive a process crash, use a durable job mechanism. Detaching a context doesn't make an in-memory goroutine durable. Consider passing just the correlation data the worker needs rather than retaining all the request's values.

In a test, cancel the parent first and then start the detached operation against a controlled client. Assert that the new context still exposes the expected correlation value and that it reaches its own deadline. In a separate request-owned test, assert that cancellation of the parent still reaches the call. Debugly's Node.js request disconnect explainer covers the complementary mistake: forgetting to pass cancellation into work that should stop with the request. Here you're removing that connection on purpose. The test should prove what replaces it.