Request cancellation is usually exactly what a Go service wants. When a client disconnects or a request deadline expires, database calls, HTTP requests, and other downstream work should normally stop too.

Sometimes one small piece of work has a different lifetime. A handler may need to enqueue an audit record, finish a bounded cache update, or send a best-effort notification after the request itself is no longer alive. Passing the request context directly makes that work inherit cancellation. Replacing it with context.Background() avoids cancellation, but also throws away useful request-scoped values.

Go 1.21 added context.WithoutCancel for this boundary. It creates a context that still resolves values through its parent but does not inherit the parent’s cancellation or deadline.

The important part is what comes next: detaching cancellation should not mean creating work with no lifetime at all.

What WithoutCancel changes

The basic operation is simple:

detached := context.WithoutCancel(parent)

The returned context behaves differently from parent in four important ways:

  • Deadline reports no deadline.
  • Done returns nil.
  • Err returns nil.
  • context.Cause(detached) returns nil.

At the same time, calls to Value continue to consult the parent context.

That makes WithoutCancel different from starting over with an empty context. If middleware stored a trace identifier or another legitimate request-scoped value in parent, the detached context can still expose it.

It also means this code does not merely ignore a single cancellation event. It removes the entire inherited cancellation and deadline relationship.

Why using the request context can fail

Consider a handler that starts asynchronous work after writing a response:

func handle(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusAccepted)

    go func() {
        if err := recordAudit(r.Context()); err != nil {
            log.Printf("audit failed: %v", err)
        }
    }()
}

This goroutine receives the request context. Its lifetime therefore remains coupled to the request. Once that context is canceled, any context-aware operation inside recordAudit can stop immediately.

That may be correct for ordinary request work, but it is a poor fit if the audit attempt is intentionally allowed to outlive the response.

One tempting replacement is:

go recordAudit(context.Background())

Now request cancellation cannot stop the work, but request-scoped values are gone as well. Code that relies on a trace ID or other propagated metadata will see a different context lineage.

WithoutCancel lets you preserve that value lookup while explicitly changing the cancellation relationship.

Add a new deadline after detaching

A detached context has no inherited deadline. Passing it directly to potentially blocking work can therefore turn a short post-request task into an unbounded goroutine.

A safer pattern is to detach first and then establish a new lifetime:

func startAudit(parent context.Context, event Event) {
    detached := context.WithoutCancel(parent)

    go func() {
        ctx, cancel := context.WithTimeout(detached, 5*time.Second)
        defer cancel()

        if err := recordAudit(ctx, event); err != nil {
            log.Printf("audit failed: %v", err)
        }
    }()
}

The order matters. context.WithTimeout(parent, 5*time.Second) would still inherit an earlier parent deadline and parent cancellation. By applying WithTimeout to the result of WithoutCancel, the new timeout becomes the lifetime boundary for this work.

This does not guarantee the operation finishes in five seconds. Cancellation in Go is cooperative: the functions doing the work must observe the context. It does, however, give context-aware calls a bounded cancellation signal instead of an immortal context.

A nil Done channel changes select behavior

Because WithoutCancel returns a context whose Done method returns nil, a receive from that channel can never proceed.

For example:

select {
case <-detached.Done():
    return detached.Err()
case job := <-jobs:
    return process(job)
}

The first case is effectively disabled. That follows normal Go channel semantics: receiving from a nil channel blocks forever.

This is another reason to add a new cancellable child when detached work can block. After WithTimeout or WithCancel, the new child has its own Done channel and can participate normally in cancellation-aware select statements.

WithoutCancel preserves values, not ownership

Context values can make detaching convenient, but they can also hide lifetime assumptions.

Suppose a context value points to an object whose owner cleans it up when the request ends. WithoutCancel can still return that value after cancellation, but it does not extend the object’s real lifetime or make the object safe for concurrent use.

The same warning applies to mutable request data. A value being reachable through a context does not mean it is appropriate for asynchronous use.

Prefer passing ordinary business data explicitly:

type AuditEvent struct {
    AccountID string
    Action    string
}

func handle(w http.ResponseWriter, r *http.Request) {
    event := AuditEvent{
        AccountID: accountIDFromRequest(r),
        Action:    "export_started",
    }

    startAudit(r.Context(), event)
    w.WriteHeader(http.StatusAccepted)
}

Use context values for the narrow metadata they are intended to carry across API boundaries, not as a substitute for defining the inputs owned by background work.

Detachment is not a goroutine manager

context.WithoutCancel only changes context propagation. It does not track the goroutine, wait for it during shutdown, recover panics, retry failures, or provide backpressure.

That distinction matters in servers. If detached work is important enough that the process must wait for it before exiting, a managed worker, queue, or explicit goroutine lifecycle is usually a better design than launching anonymous goroutines from handlers.

For example, a service can own a worker group whose lifetime is tied to the process rather than to individual requests. Handlers submit immutable jobs, and shutdown waits for workers according to a documented policy. In that architecture, WithoutCancel may not be needed at all because the background subsystem already has the correct parent context.

Use detachment for genuinely small lifetime boundaries, not as a replacement for application-level concurrency management.

Do not detach work that should stop with the caller

Most operations should continue to use the original context.

Database queries whose results are needed only for the response should stop when the request is canceled. Outbound HTTP calls made to construct that response should stop too. CPU-intensive request processing should not continue merely because it has already started.

Detaching these operations wastes resources and can make overload worse. Cancellation propagation exists so abandoned work can be discarded promptly.

A useful design question is: who still needs this result after the parent operation ends? If the answer is nobody, keep the parent cancellation.

Preserve only values you are willing to propagate

WithoutCancel delegates value lookup to its parent. That is convenient, but it means all context values remain reachable, not just the one value the detached operation happens to need today.

If the work crosses a trust, privacy, or subsystem boundary, consider constructing a fresh context and explicitly copying only safe metadata instead of preserving the entire value chain.

For example, a package can expose typed accessors for a trace identifier and attach only that identifier to a new context. Better yet, where an API accepts explicit metadata, pass it as a normal argument rather than relying on context values.

Detachment should not accidentally broaden the lifetime of credentials, authorization state, large request objects, or other values that were intended to be request-scoped.

Test the lifetime boundary directly

The semantics are easy to verify without sleeps. A test can cancel the parent and inspect both contexts:

func TestWithoutCancel(t *testing.T) {
    type key struct{}

    parent, cancel := context.WithCancel(
        context.WithValue(context.Background(), key{}, "trace-123"),
    )

    detached := context.WithoutCancel(parent)
    cancel()

    if parent.Err() != context.Canceled {
        t.Fatalf("parent error = %v", parent.Err())
    }
    if detached.Err() != nil {
        t.Fatalf("detached error = %v", detached.Err())
    }
    if detached.Done() != nil {
        t.Fatal("detached Done channel is not nil")
    }
    if _, ok := detached.Deadline(); ok {
        t.Fatal("detached context unexpectedly has a deadline")
    }
    if got := detached.Value(key{}); got != "trace-123" {
        t.Fatalf("value = %v", got)
    }
    if cause := context.Cause(detached); cause != nil {
        t.Fatalf("cause = %v", cause)
    }
}

For code that adds a new timeout, test that boundary separately. Prefer explicit cancellation or short deterministic coordination over tests that depend on arbitrary scheduling delays.

Go version compatibility

context.WithoutCancel was added in Go 1.21. Code that calls it directly therefore requires a toolchain and module compatibility level where that API is available.

If a library must support older Go releases, do not silently emulate the function without documenting the behavioral contract. The details matter: preserving Value while removing Deadline, Done, Err, and cancellation cause is the feature, not merely returning a context that happens not to be canceled yet.

The practical rule

Use context.WithoutCancel when a child operation intentionally needs to outlive its parent and should retain appropriate context values. Then give that operation a new, explicit lifetime with WithTimeout, WithDeadline, WithCancel, or a higher-level worker lifecycle.

Do not use it simply to make cancellation errors disappear. Parent cancellation is normally valuable feedback that work is no longer needed.

The safest mental model is that WithoutCancel cuts one lifetime relationship while preserving value lookup. Once you cut that relationship, ownership becomes your responsibility: decide how the new work ends, which data it may retain, how failures are observed, and whether process shutdown needs to wait for it.