How to Use context.WithoutCancel (Go 1.21+)

context.WithoutCancel creates a derived context that ignores parent cancellation while preserving values and deadlines.

The background job that survives the client

You are building an API that accepts file uploads. A client sends a large video file. Your server receives it and starts a background goroutine to transcode the video. The transcoding takes thirty seconds. The client's internet connection drops after five seconds. The HTTP request ends. The request context cancels. Your background goroutine checks the context, sees the cancellation, and stops immediately. The video is half-processed. The database record is left in a broken state. The user retries the upload and gets a conflict error.

You need the background work to finish even if the client disappears. At the same time, you still want the server to enforce a maximum runtime so a stuck job doesn't hold resources forever. You also need to pass tracing IDs and user metadata to the background job so logs stay correlated.

context.WithoutCancel solves this. It creates a derived context that ignores cancellation signals from its parent while preserving the deadline and all stored values. It was added in Go 1.21 to handle exactly this pattern: detaching a long-lived task from a short-lived trigger without losing the safety of deadlines or the utility of values.

How it works

A standard context propagates cancellation. When a parent context cancels, all children cancel immediately. This is the default behavior and it is correct for most request-scoped work. If the request dies, the work should die.

context.WithoutCancel breaks the cancellation chain. It creates a new context that has its own Done channel. This new channel does not listen to the parent's Done channel. If the parent cancels, the child keeps running.

The child does copy two things from the parent. It copies the deadline. If the parent has a deadline of ten seconds, the child also has a deadline of ten seconds. When that time passes, the child cancels itself. It also copies the values. Any key-value pairs stored in the parent, like trace IDs or user IDs, are available in the child.

Think of the parent context as a power strip. The values are the labels on the outlets. The deadline is a timer on the strip. The cancellation is the master switch. WithoutCancel plugs your device into a battery backup. If someone flips the master switch, your device keeps running. The timer still works. The labels are still readable. But the master switch no longer controls your device.

Context is plumbing. Run it through every long-lived call site.

Minimal example

Here is the simplest way to detach cancellation while keeping the deadline and values.

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	// Create a context with a deadline to simulate a request timeout.
	// The deadline ensures the context will eventually cancel itself.
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// Strip the cancellation signal. The new context inherits the deadline
	// and values from ctx, but calling cancel() will not affect it.
	safeCtx := context.WithoutCancel(ctx)

	// Cancel the parent context immediately.
	cancel()

	// The parent context is now done.
	fmt.Println("Parent done:", ctx.Err() != nil)

	// The safe context is still alive because it ignores the parent's cancellation.
	// It will only become done when the 5-second deadline passes.
	fmt.Println("Safe done:", safeCtx.Err() != nil)

	// Wait for the deadline to pass so we can see the safe context expire.
	time.Sleep(6 * time.Second)

	// Now the safe context is done because the deadline elapsed.
	fmt.Println("Safe done after deadline:", safeCtx.Err() != nil)
}

The output shows the parent dying instantly while the child survives until the deadline.

# output:
Parent done: true
Safe done: false
Safe done after deadline: true

Inside the mechanism

When you call context.WithoutCancel, the implementation creates a new cancelCtx struct. This struct holds a done channel that is separate from the parent's done channel. It also stores the deadline and a copy of the value map.

The new context registers a timer for the deadline. If the deadline exists, the timer fires at the deadline time and closes the new done channel. This causes safeCtx.Done() to return and safeCtx.Err() to return context.DeadlineExceeded.

Values are copied by reference to the underlying map structure. Context values are immutable in practice. You never modify a context after creation. The copy is safe because the map contents do not change. If you store a pointer in a context value, the pointer is copied, and the pointed-to data is shared. This is standard context behavior, not specific to WithoutCancel.

The receiver name convention in Go is usually one or two letters matching the type. If you write a helper that wraps this, name the receiver c for context, not this or self.

func (c *MyContext) Detach() context.Context {
	return context.WithoutCancel(c)
}

Realistic example: HTTP handler with background processing

A common use case is an HTTP handler that starts a background job. The request context is tied to the connection. If the client disconnects, the context cancels. You want the job to continue, but you want to respect the server's timeout policy and pass the request ID for logging.

Here is a handler that starts a background worker using WithoutCancel.

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

// handleUpload starts a background job to process a file.
// It detaches the job from the request lifecycle so client disconnects
// do not stop the processing, but it preserves the deadline and trace values.
func handleUpload(w http.ResponseWriter, r *http.Request) {
	// r.Context() is tied to the request. It cancels when the client
	// disconnects or when the server's ReadTimeout fires.
	reqCtx := r.Context()

	// Create a context for the background job.
	// WithoutCancel strips the cancellation signal from reqCtx.
	// The job will still stop if the deadline passes.
	// This assumes reqCtx has a deadline set by middleware or the server config.
	jobCtx := context.WithoutCancel(reqCtx)

	// Launch the background goroutine.
	// Pass jobCtx so the worker can check for deadline expiration.
	go processFile(jobCtx, "video.mp4")

	// Respond immediately so the client can disconnect without breaking the job.
	w.WriteHeader(http.StatusAccepted)
	fmt.Fprint(w, "Processing started")
}

// processFile simulates a long-running task.
// It respects the context deadline but ignores request cancellation.
func processFile(ctx context.Context, filename string) {
	// Simulate work with a select loop.
	// The worker checks ctx.Done() to see if the deadline has passed.
	select {
	case <-ctx.Done():
		// This branch runs only if the deadline expires.
		// It will not run if the HTTP request context was cancelled.
		fmt.Println("Job stopped due to deadline:", ctx.Err())
	case <-time.After(10 * time.Second):
		// Work completed successfully.
		fmt.Println("Job finished for", filename)
	}
}

The processFile function checks ctx.Done(). Because jobCtx ignores the request cancellation, the select will not trigger on client disconnect. It only triggers when the deadline passes. This keeps the job alive across client flakiness while still honoring server limits.

The convention for error handling in Go is verbose by design. If processFile returns an error, the caller should check if err != nil { return err }. This boilerplate makes the unhappy path visible. In a background goroutine, you often log the error instead of returning it, since there is no caller to return to.

Pitfalls and errors

The biggest risk with WithoutCancel is goroutine leaks. If you strip the cancellation signal and the context has no deadline, the resulting context never cancels. Any goroutine waiting on ctx.Done() will block forever. If the goroutine holds resources like database connections or file handles, those resources leak until the process restarts.

Always ensure the context passed to WithoutCancel has a deadline. If the parent context does not have a deadline, wrap it with context.WithTimeout before calling WithoutCancel.

// Dangerous: no deadline. The job runs forever if it blocks.
// jobCtx := context.WithoutCancel(r.Context())

// Safe: adds a deadline if one doesn't exist.
jobCtx := context.WithoutCancel(context.WithTimeout(r.Context(), 30*time.Second))

Another pitfall is assuming WithoutCancel makes the context immortal. It does not. The deadline still kills the context. If you need an immortal context, use context.Background() directly. WithoutCancel is for preserving deadlines and values while dropping cancellation.

If you try to use context.WithoutCancel on a Go version older than 1.21, the compiler rejects the program with undefined: context.WithoutCancel. You must upgrade your toolchain or write a custom wrapper that manually copies the deadline and values.

The compiler also complains with cannot use x (type context.Context) as type context.Context in argument if you accidentally mix context types from different packages, though this is rare with the standard library. Stick to context.Context from context.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Even with WithoutCancel, the deadline provides that path. Trust the deadline.

Decision matrix

Use context.WithoutCancel when you need to detach a background task from a request lifecycle while preserving the deadline and values.

Use context.WithTimeout when you need to limit how long an operation can run, regardless of whether the parent context has a deadline.

Use context.WithCancel when you need to stop a group of goroutines manually based on application logic.

Use the original context when the operation should stop immediately if the parent cancels, which is the correct choice for most request-scoped work.

Stripping cancellation removes the emergency brake. Install a deadline or you will crash.

Where to go next