Fix

"context canceled" in Go

Fix 'context canceled' in Go by ensuring parent contexts aren't canceled prematurely and handling the error gracefully.

The mystery of the disappearing context

You are building an API. A client sends a request, your server starts processing, and suddenly the logs fill up with context canceled. The request didn't timeout. The client didn't disconnect. You just added a goroutine to do some background work, and now everything is failing. The error message is accurate, but it feels like a mystery. The context was canceled, but who pulled the plug?

This error is the most common symptom of a mismatch between your code's lifecycle and the context's lifecycle. Go uses contexts to manage signals, not just data. When you see context canceled, the runtime is telling you that a parent operation decided to stop, and your code is trying to continue anyway.

Context is a signal tree

A context.Context is a value that carries deadlines, cancellation signals, and request-scoped values across API boundaries. It forms a tree. The root is context.Background(). Every time you call context.WithCancel, context.WithTimeout, or context.WithValue, you create a child node attached to a parent.

When a context gets canceled, the cancellation propagates down the tree. Canceling a parent cancels all its children. Canceling a child does not affect siblings or the parent. The "context canceled" error appears when a function checks the context and finds that the cancellation signal has been triggered.

Think of a context like a walkie-talkie channel shared by a team. The leader holds the master switch. When the leader flips the switch to cancel, every team member hears a static burst. If a team member is in the middle of a task, they check their radio, hear the signal, and drop their work. If they ignore the radio and keep working, they are wasting effort on a task that no longer matters.

Minimal example: respecting the signal

The standard way to handle cancellation is to check ctx.Err() before starting work and use select to listen for ctx.Done() during blocking operations.

package main

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

// DoWork simulates a long-running task that respects cancellation.
func DoWork(ctx context.Context) error {
	// Check if the context is already canceled before doing work.
	// This avoids wasting resources on a doomed operation.
	if err := ctx.Err(); err != nil {
		return err
	}

	// Use select to wait for either the work to finish or the context to cancel.
	// This makes the blocking call interruptible.
	select {
	case <-time.After(2 * time.Second):
		fmt.Println("Work finished")
		return nil
	case <-ctx.Done():
		// ctx.Done() returns a channel that closes when the context is canceled.
		// Reading from a closed channel returns immediately.
		return ctx.Err()
	}
}

func main() {
	// Create a context that can be canceled manually.
	ctx, cancel := context.WithCancel(context.Background())
	// Always call cancel to release resources, even if the context isn't canceled.
	// This prevents memory leaks in the context tree.
	defer cancel()

	// Start a goroutine to cancel after a short delay.
	go func() {
		time.Sleep(500 * time.Millisecond)
		cancel()
	}()

	err := DoWork(ctx)
	if err != nil {
		fmt.Println("Error:", err)
	}
}

The output shows the error immediately:

Error: context canceled

How the cancellation flows

When you call context.WithCancel, Go creates a new context node and a channel called done. The cancel function closes this channel. When cancel runs, every goroutine waiting on ctx.Done() wakes up because reading from a closed channel returns immediately with the zero value. The ctx.Err() method then returns the sentinel error context.Canceled.

The Go community has a strict convention for contexts. The context must be the first parameter of a function. The parameter name should almost always be ctx. This makes it easy to spot in long signatures and allows tools to analyze cancellation flow. If you see a function that takes a context as the second argument, it is fighting the ecosystem.

Another convention is mandatory resource cleanup. You must call cancel() when you are done with a context derived from WithCancel, WithTimeout, or WithDeadline. Even if you never intend to cancel, calling cancel releases internal timers and channels. Forgetting to call cancel can leak resources until the garbage collector runs, which might be much later than expected.

The realistic trap: background goroutines

The most common cause of unexpected context canceled errors is passing a request context to a background goroutine. In an HTTP handler, r.Context() is tied to the request lifecycle. The server cancels this context automatically when the handler returns or the client disconnects.

If you spawn a goroutine to do work after sending the response, and you pass r.Context() to that goroutine, the context will be canceled the moment the handler finishes. The goroutine will fail with context canceled even though the work is still valid.

package main

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

// HandleRequest demonstrates the common trap of leaking a request context.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
	// r.Context() is tied to the request lifecycle.
	// It gets canceled automatically when the handler returns.
	reqCtx := r.Context()

	// Spawning a goroutine with reqCtx is dangerous.
	// The goroutine may outlive the request, causing ctx.Err() to return context.Canceled.
	go func() {
		time.Sleep(1 * time.Second)
		// This call will likely fail because the handler returned and canceled reqCtx.
		if err := doBackgroundWork(reqCtx); err != nil {
			fmt.Println("Background work failed:", err)
		}
	}()

	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "Request accepted")
}

// doBackgroundWork simulates a task that needs a context.
func doBackgroundWork(ctx context.Context) error {
	select {
	case <-time.After(500 * time.Millisecond):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

The fix is to detach the background task from the request context. Go 1.21 introduced context.WithoutCancel, which creates a new context that ignores cancellation signals but preserves values like trace IDs.

// HandleRequestFixed shows how to detach a background task from the request.
func HandleRequestFixed(w http.ResponseWriter, r *http.Request) {
	// WithoutCancel creates a new context that ignores cancellation signals.
	// It preserves values like trace IDs but detaches the lifecycle.
	// This is available in Go 1.21 and later.
	detachedCtx := context.WithoutCancel(r.Context())

	go func() {
		// The background task runs independently.
		// It will not be killed when the HTTP response is sent.
		err := doBackgroundWork(detachedCtx)
		if err != nil {
			fmt.Println("Background work failed:", err)
		}
	}()

	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "Request accepted")
}

If you are on an older version of Go, you can use context.Background() for the background task, but you lose the ability to propagate request-scoped values. context.WithoutCancel is the preferred approach when values matter.

Pitfalls and error handling

The compiler does not check context lifecycles. You can pass any context to any function, and the compiler will accept it. The error only appears at runtime. If you pass a nil context to a function that calls ctx.Err(), the program panics with runtime error: invalid memory address or nil pointer dereference. Always use context.Background() or context.TODO() as a base. Never pass nil.

When handling the error, use errors.Is to check for cancellation. The error value is a sentinel. Wrapping it preserves the identity. If you wrap the error with fmt.Errorf("failed: %w", ctx.Err()), errors.Is still works.

import "errors"

if errors.Is(err, context.Canceled) {
	// Handle graceful shutdown or skip logging.
	return
}

There are two distinct cancellation errors. context.Canceled means a cancel function was called. context.DeadlineExceeded means a timer fired. Both indicate the operation should stop, but the cause differs. Your error handling logic should usually treat them the same way: stop the work and return.

A background goroutine with a request context is a ticking time bomb. Detach the context or use a dedicated worker pool.

When to use which context function

Use context.Background() when you are starting a top-level operation with no existing context.

Use context.WithCancel when you need to manually stop a group of goroutines from a single point.

Use context.WithTimeout when an operation must finish within a specific duration, or fail.

Use context.WithDeadline when you have a fixed timestamp by which the work must complete.

Use context.WithoutCancel when you need to spawn a background task that must survive the parent request, but should still carry trace values.

Use ctx.Err() to check for cancellation before starting expensive work.

Use select with ctx.Done() to make blocking calls interruptible.

Check ctx.Err() before you start. Save the work, not the error.

Where to go next