How to Propagate Cancellation with Context in Go

Use context.Context with cancel functions to signal goroutines to stop, checking ctx.Done() or ctx.Err() to exit tasks immediately.

The kill switch for goroutines

You built a background job that processes a large file. The user changes their mind and clicks "Cancel" in the browser. The request handler returns, but the goroutine keeps crunching numbers, eating CPU, and holding onto a file lock. The server is stuck. You need a way to tell that goroutine to stop, right now, and clean up after itself.

Go solves this with context.Context. The context package provides a standard way to pass deadlines, cancellation signals, and request-scoped data across API boundaries and into goroutines. It is the mechanism that lets a parent signal a child to halt, and it ensures that signal propagates through the entire call tree.

Context is a walkie-talkie channel

Think of a context like a walkie-talkie channel that carries two things: metadata and a kill switch. When you create a context, you open a channel. You can pass that channel to any function or goroutine. When you trigger cancellation, every listener on that channel hears the signal instantly.

Contexts form a tree. When you create a new context from an existing one, you attach it as a child. Canceling a parent automatically cancels all its children. This structure matches your code's call graph. If a request ends, the context tree rooted at that request collapses, stopping every goroutine and blocking call that depends on it.

The context doesn't do the work. It just broadcasts the order to stop. Your code must listen for the signal and act on it.

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

Minimal cancellation

Here's the skeleton of cancellation: create a context, spawn a worker, trigger cancel.

package main

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

func main() {
	// Create a cancellable context. cancel() is the kill switch.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // Always call cancel to release resources.

	// Start a goroutine that watches the context.
	go func() {
		// Block until cancellation or timeout.
		select {
		case <-ctx.Done():
			// Context was cancelled. Print the reason.
			fmt.Println("Stopped:", ctx.Err())
		case <-time.After(5 * time.Second):
			fmt.Println("Work finished")
		}
	}()

	// Wait a bit, then pull the trigger.
	time.Sleep(2 * time.Second)
	cancel()

	// Give the goroutine time to react.
	time.Sleep(1 * time.Second)
}

context.WithCancel returns a new context and a cancel function. The context holds a channel internally. Calling cancel() closes that channel. In the goroutine, <-ctx.Done() blocks until the channel closes. When the channel closes, the select case fires. ctx.Err() returns context.Canceled. The goroutine can then return early, closing files or releasing locks.

The defer cancel() in the caller ensures the context is cleaned up even if the function returns early. Failing to call cancel can leak resources if the context has a deadline or holds values. The convention is to call cancel as soon as the work is done, usually via defer.

The context carries the signal. The code must listen.

Real-world: HTTP handlers and workers

In a web server, the request context is your lifeline. The http.Request provides a context that cancels automatically when the client disconnects. You thread this context through your handlers and workers so that a dropped connection stops the server-side work immediately.

Here's how to thread it through a handler and a worker function.

package main

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

// processRequest does work and respects cancellation.
// ctx first by convention.
func processRequest(ctx context.Context, id string) error {
	for i := 0; i < 10; i++ {
		// Check for cancellation before each step.
		select {
		case <-ctx.Done():
			return fmt.Errorf("step %d: %w", i, ctx.Err())
		default:
			// Simulate work.
			time.Sleep(500 * time.Millisecond)
		}
	}
	return nil
}

// handler serves the request.
func handler(w http.ResponseWriter, r *http.Request) {
	// r.Context() cancels automatically when the client disconnects.
	ctx := r.Context()

	if err := processRequest(ctx, "job-123"); err != nil {
		fmt.Println("Failed:", err)
		return
	}
	fmt.Fprint(w, "ok")
}

The worker function takes ctx as the first parameter. This is the universal Go convention: if a function accepts a context, it is always the first argument, and it is usually named ctx. Functions that take a context should respect cancellation and deadlines. The worker checks ctx.Done() before each step. If the client disconnects, ctx.Done() fires, the worker returns an error, and the handler logs it.

The select with default is a common pattern for non-blocking checks. It allows the loop to proceed if the context is still active, but exits immediately if cancellation arrives. Without this check, the loop might run to completion even after the client is gone, wasting resources.

If your function takes a long time, it takes a context.

Pitfalls and leaks

Cancellation only works if you check for it. A goroutine that blocks on a channel, a file read, or a network call without watching the context will ignore the signal and leak. The goroutine stays alive, holding memory and references, until the program exits.

The compiler catches missing variables. If you forget to pass the context, you get undefined: ctx. If you try to pass a context where a string is expected, the compiler rejects it with cannot use ctx (variable of type context.Context) as string value. These errors are helpful. The runtime doesn't panic on cancellation; it returns an error. The danger is silent leaks.

A common mistake is using time.Sleep or blocking I/O without a select. If a goroutine calls time.Sleep(10 * time.Second) and the context cancels after one second, the sleep continues. The goroutine doesn't wake up until the sleep finishes. You must use select to make blocking operations responsive to the context.

Another trap is storing contexts in structs. Contexts are request-scoped. They should flow through functions, not sit in fields. Storing a context in a struct couples the struct to a specific request lifecycle, which breaks reusability and causes confusion when the context expires. Pass the context as an argument.

A goroutine that ignores cancellation is a memory leak waiting to happen.

When to use context

Go provides several ways to create contexts. Pick the right one for the job.

Use context.WithCancel when you need manual control over cancellation from a specific point in the code.

Use context.WithTimeout when the operation must finish within a fixed duration, regardless of external signals.

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

Use r.Context() in HTTP handlers to tie the work to the client connection.

Use errgroup.WithContext when you have a fan-out of goroutines and want one failure to cancel the rest.

Use a plain channel when you only need a simple signal between two goroutines and don't need the context tree structure.

Context is for cancellation and request-scoped data. Don't use it to pass optional parameters.

Where to go next