Ch. 25 · Go

Go Context: Cancellation, Timeouts and Deadlines

How context cancellation propagates through a Go call tree, why cancel must always be called, and how to stop goroutines on a deadline.

~7 min readintermediateupdated Oct 6, 2026

“What is context.Context for?” and “How do you stop a goroutine?” are two of the most common Go interview questions, and they share an answer. Interviewers then dig: what happens if you forget cancel, can a child context have a longer timeout than its parent, why should a context never live in a struct, and what happens to in-flight work when the client disconnects? Those questions come from production outages where one slow dependency tied up every goroutine in a service.

Context is how Go programs agree on when to give up. This guide shows how the signal flows, how to honour it in your own code, and how to deliberately escape it. Examples target Go 1.23.

Before you start

You should know how goroutines, channels and select work, in particular that receiving from a closed channel returns immediately. Basic familiarity with net/http servers and clients helps, as does knowing that Go has no way to kill a goroutine from outside.

The short answer

A context.Context carries a cancellation signal, an optional deadline and a few request-scoped values across API boundaries. You derive child contexts with WithCancel, WithTimeout or WithDeadline; cancelling a parent cancels all its descendants, but never the other way round. Cancellation is cooperative: ctx.Done() returns a channel that closes when the context ends, and code must watch it, after which ctx.Err() reports context.Canceled or context.DeadlineExceeded. Every derive returns a cancel function that you must call, usually with defer, to release resources.

How it works

The interface is small:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
go

context.Background() is the root: never cancelled, no deadline, no values. Each With... function wraps a parent and registers the child with it, building a tree. When a node is cancelled, it closes its Done channel, records its error, and cancels every registered child. A closed channel wakes all receivers at once, which is why one cancellation can stop thousands of goroutines.

root := context.Background()
reqCtx, cancelReq := context.WithCancel(root)
dbCtx, cancelDB := context.WithTimeout(reqCtx, 2*time.Second)
defer cancelDB()

cancelReq()                // cancel the parent...
<-dbCtx.Done()             // ...and the child is done too
fmt.Println(dbCtx.Err())   // context canceled
go

WithTimeout(parent, d) is WithDeadline(parent, time.Now().Add(d)). If the parent already has an earlier deadline, the child simply inherits it: a child can shorten a deadline but never extend it. The error tells you why the context ended: context.Canceled when someone called cancel, context.DeadlineExceeded when the clock ran out. Since Go 1.20, WithCancelCause and context.Cause(ctx) let you attach a more specific reason.

Calling cancel matters even when the work finishes normally. Until it is called, the child stays registered with its parent and a timeout keeps its timer, so a long-lived parent accumulates children. go vet reports a discarded cancel function as lostcancel.

Step-by-step walkthrough

Step 1: Accept a context first and pass it to every I/O call

func (s *Service) Quote(ctx context.Context, sku string) (Quote, error) {
	row := s.db.QueryRowContext(ctx, "SELECT price FROM prices WHERE sku = $1", sku)
	var q Quote
	if err := row.Scan(&q.Price); err != nil {
		return Quote{}, fmt.Errorf("price for %s: %w", sku, err)
	}
	return q, nil
}
go

The convention is the first parameter, named ctx. Standard library calls with a Context variant, such as QueryRowContext, http.NewRequestWithContext and net.Dialer.DialContext, abort and return the context’s error when it ends. A function that drops the context breaks the chain for everything below it.

Step 2: Put a deadline on each outbound call

func fetchPrice(ctx context.Context, baseURL string) (Price, error) {
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/price", nil)
	if err != nil {
		return Price{}, err
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return Price{}, fmt.Errorf("fetch price: %w", err)
	}
	defer resp.Body.Close()

	var p Price
	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
		return Price{}, fmt.Errorf("decode price: %w", err)
	}
	return p, nil
}
go

The local timeout bounds this dependency, while the parent’s deadline, if shorter, still wins. Because the error is wrapped with %w, callers can test errors.Is(err, context.DeadlineExceeded).

Step 3: Make your own loops cancellable

func consume(ctx context.Context, jobs <-chan Job) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case j, ok := <-jobs:
			if !ok {
				return nil
			}
			if err := process(ctx, j); err != nil {
				return err
			}
		}
	}
}
go

Every blocking operation in a long-running goroutine should sit in a select with ctx.Done(). For CPU-bound loops with no channel operations, check ctx.Err() every so many iterations; cancellation cannot interrupt code that never looks.

Step 4: Follow the client in HTTP handlers

func (h *Handler) Report(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // cancelled when the client disconnects or ServeHTTP returns
	rows, err := h.store.Report(ctx, r.URL.Query().Get("month"))
	if errors.Is(err, context.Canceled) {
		return // client is gone: nobody to answer
	}
	// ...
}
go

The server cancels the request’s context when the connection closes, so an abandoned three-second report query stops instead of finishing for nobody.

Step 5: Detach work that must outlive the request

func (h *Handler) Checkout(w http.ResponseWriter, r *http.Request) {
	// ... charge and respond ...
	bg := context.WithoutCancel(r.Context()) // Go 1.21: keeps values, drops cancellation
	go func() {
		ctx, cancel := context.WithTimeout(bg, 5*time.Second)
		defer cancel()
		h.audit.Record(ctx, "checkout", orderID)
	}()
}
go

WithoutCancel keeps request values such as trace IDs but is not cancelled with the request and has no deadline, so you add a fresh timeout. Go 1.21 also added context.AfterFunc, which runs a callback once a context is done.

Worked scenario

A checkout endpoint calls a pricing service and then writes an audit record. One afternoon the pricing service starts hanging, and within minutes the checkout service has 40,000 goroutines and stops answering health checks. After a fix is deployed, the audit table starts missing rows, with context canceled in the logs.

// Broken
func (h *Handler) Checkout(w http.ResponseWriter, r *http.Request) {
	req, _ := http.NewRequest(http.MethodGet, h.pricingURL, nil) // no context
	resp, err := http.DefaultClient.Do(req)                       // default client: no timeout
	// ...
	go h.audit.Record(r.Context(), "checkout", orderID) // dies when the handler returns
}
go

The first bug: the outbound request has no context and the default client has no timeout, so each hung call holds its goroutine and connection forever, even after the customer gives up. The second bug, introduced by the “fix” that started passing r.Context() everywhere: the audit goroutine runs after the handler returns, at which point the request context is cancelled, so most writes abort.

// Fixed
func (h *Handler) Checkout(w http.ResponseWriter, r *http.Request) {
	price, err := fetchPrice(r.Context(), h.pricingURL) // 800 ms cap, client disconnect honoured
	if err != nil {
		http.Error(w, "pricing unavailable", http.StatusServiceUnavailable)
		return
	}
	// ... charge using price ...
	bg := context.WithoutCancel(r.Context())
	go func() {
		ctx, cancel := context.WithTimeout(bg, 5*time.Second)
		defer cancel()
		h.audit.Record(ctx, "checkout", orderID)
	}()
}
go

Request-scoped work follows the request; follow-up work gets its own, bounded lifetime. For durable guarantees, write the audit event to a queue or outbox in the same transaction instead of a goroutine.

Common mistake

  • “Cancel kills the goroutine.” It only closes a channel. Code that never checks Done() keeps running.
  • Forgetting cancel, which keeps timers and child registrations alive until the parent ends.
  • Storing a context in a struct and reusing it across requests, so one request’s cancellation affects another or none at all.
  • Passing nil as a context. Start from context.Background() in main, tests and other entry points.
  • Using WithValue for parameters such as user IDs that a function needs to work; pass them explicitly.
  • Using r.Context() for background work that runs after the response.

Verify the behavior

func TestFetchPriceRespectsDeadline(t *testing.T) {
	slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-time.After(2 * time.Second):
		case <-r.Context().Done(): // the server sees the client give up
		}
	}))
	defer slow.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	start := time.Now()
	_, err := fetchPrice(ctx, slow.URL)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("want deadline exceeded, got %v", err)
	}
	if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
		t.Fatalf("took %v; the deadline was not honoured", elapsed)
	}
}
go

The test proves two things: the parent’s 50 ms deadline beats the function’s own 800 ms timeout, and the call returns promptly with an error that callers can classify. Run it with go test -run TestFetchPriceRespectsDeadline -race -v.

Follow-up questions

How do you report why a context was cancelled? Create it with context.WithCancelCause (Go 1.20), call cancel(err), and read the reason with context.Cause(ctx); ctx.Err() still returns context.Canceled.

How do deadlines cross service boundaries? Not automatically over plain HTTP. gRPC propagates the remaining deadline in a header; for HTTP you pass your own header or set a per-call timeout.

Why should values use an unexported key type? Plain string keys from different packages can collide. A private type ctxKey struct{} cannot.

How do you cancel on SIGTERM? signal.NotifyContext(context.Background(), syscall.SIGTERM) returns a context that is cancelled when the signal arrives.

Interview exercise

A handler creates parent, cancel := context.WithTimeout(ctx, 100*time.Millisecond) and then calls a helper that does child, cancel2 := context.WithTimeout(parent, 5*time.Second). What does child.Deadline() return, when does child.Done() close, and what is child.Err() afterwards?

Answer and reasoning

child.Deadline() returns the parent’s deadline, about 100 ms from creation, with ok == true. When the parent’s deadline is already earlier than the requested one, WithDeadline returns a context that behaves like a plain cancellable child of the parent, so no 5-second timer is created. child.Done() closes at roughly 100 ms, when the parent expires and propagates its cancellation, and child.Err() is context.DeadlineExceeded, the parent’s reason. Calling cancel2 early would end only the child. The answer demonstrates the core rule: deadlines flow down and can only get shorter.

Continue learning

Practise with the Go interview questions and the Go MCQs. Related notes: Go worker pools and goroutine leaks, Go buffered vs unbuffered channels and Node.js graceful shutdown. Primary sources: the context package documentation, the Go blog post Go concurrency patterns: context and the Go 1.21 release notes for WithoutCancel and AfterFunc.

More in Go

esc