A Go context usually ties a unit of work to the lifetime of its parent. Cancel an HTTP request context, and derived contexts observe that cancellation. This propagation is the normal contract, but some follow-up operations need a different lifetime while still carrying request-scoped values.
Go 1.21 added context.WithoutCancel for that boundary. It returns a context that can resolve values through its parent but does not inherit the parent’s cancellation state or deadline.
Detachment changes more than the Done channel
context.WithoutCancel(parent) is not equivalent to copying a context and ignoring one receive from Done. The returned context has a specific set of semantics:
Done()returnsnil.Err()returnsnil.Deadline()reports no deadline.context.Causereturnsnil.Valuestill resolves values from the parent chain.
That combination matters when the parent is already canceled. Detachment does not create a delayed cancellation event. The new context starts with no cancellation relationship to the parent at all.
package main
import (
"context"
"fmt"
)
type key string
func main() {
parent := context.WithValue(context.Background(), key("request-id"), "req-42")
parent, cancel := context.WithCancel(parent)
cancel()
detached := context.WithoutCancel(parent)
_, hasDeadline := detached.Deadline()
fmt.Println(parent.Err())
fmt.Println(detached.Err())
fmt.Println(detached.Done() == nil)
fmt.Println(hasDeadline)
fmt.Println(detached.Value(key("request-id")))
}The detached context still exposes req-42, even though cancellation and deadline information no longer cross the boundary.
A nil Done channel also has normal Go channel semantics. A receive from it blocks forever. In a select, a case using that channel is disabled, so another case must provide progress or termination.
The lifetime boundary is explicit
A common use for detachment is short follow-up work that should survive the end of a request. Logging, cache updates, or event publication can fit this shape when their correctness does not depend on the client connection remaining open.
Passing context.Background() would also remove request cancellation, but it drops values carried by the request context. Passing the request context directly preserves values and preserves cancellation. WithoutCancel occupies the narrower middle position: keep value lookup, discard the parent’s lifetime controls.
That does not make the detached operation unbounded by necessity. A new lifetime can be attached immediately:
func publishReceipt(requestCtx context.Context, receipt Receipt) error {
base := context.WithoutCancel(requestCtx)
ctx, cancel := context.WithTimeout(base, 2*time.Second)
defer cancel()
return publisher.Publish(ctx, receipt)
}Here the two-second timeout belongs to the publication operation rather than to the request. If the request is canceled after the handler starts publication, that cancellation does not stop the call. The publication still has a finite deadline of its own.
This distinction is useful because cancellation describes ownership. A request-scoped operation is owned by the request. A detached operation needs another owner, such as its own timeout, a service shutdown context, or a worker subsystem with an explicit lifecycle.
Parent values remain visible
Detachment retains access to the parent’s values rather than taking a snapshot of them. Context values are resolved through the parent chain, so identifiers or tracing metadata stored before detachment remain available.
This behavior should not be used to turn context into a general parameter container. The standard context contract is still aimed at request-scoped data that crosses API boundaries. Configuration, dependencies, and ordinary function arguments remain better represented directly.
There is also a lifetime consequence. Because the detached context refers to its parent for value lookup, values reachable through that chain can remain reachable for as long as the detached context remains in use. Long-lived detached work therefore deserves the same care as any other object graph that retains request data.
Cancellation causes do not cross the boundary
Cause-aware cancellation does not alter the detachment rule. If a parent is canceled with context.WithCancelCause, a context returned by WithoutCancel reports no error and no cause.
parent, cancel := context.WithCancelCause(context.Background())
cancel(errors.New("client disconnected"))
detached := context.WithoutCancel(parent)
fmt.Println(parent.Err()) // context canceled
fmt.Println(context.Cause(parent)) // client disconnected
fmt.Println(detached.Err()) // <nil>
fmt.Println(context.Cause(detached)) // <nil>This is a semantic boundary, not an error-filtering mechanism. Code that needs the original cancellation cause must capture or pass that information separately before detaching.
The same rule applies to deadlines. A parent deadline does not appear through Deadline() on the detached context, and its expiration does not close the detached context’s Done channel.
Detachment is not a goroutine lifecycle
WithoutCancel changes context propagation; it does not manage goroutines. Starting detached work in a goroutine still requires a lifecycle that matches the process and the operation.
For small bounded operations, a fresh timeout may be enough. For work that must complete during service shutdown, handing the job to a managed worker can provide a clearer ownership model. The worker can accept request metadata as data and use a service-level context for shutdown.
The key boundary is whether the work should remain coupled to the parent operation. If it should stop when the request stops, ordinary context propagation is the correct behavior. If it must continue but still needs context values, WithoutCancel expresses that separation directly. Once cancellation is detached, the code that creates the new lifetime also becomes responsible for defining its end.