PRACTICAL GO · FREE GUIDE · NOVEMBER 4, 2026
Why context cancellation doesn't stop your Go goroutine
A request times out. Its handler returns. What happens to the goroutine that is still fetching data?
There are two separate ways to get stuck
Consider this simplified request handler. Assume data is an unbuffered channel and Fetch performs a slow operation:
data := make(chan string)
go func() {
data <- store.Fetch()
}()
select {
case value := <-data:
use(value)
case <-ctx.Done():
return
}First, Fetch() can keep running after the handler returns. Nothing here connects the context to that operation. Second, when Fetch finishes, its send can wait forever: the only receiver has already left. Fixing delivery does not automatically fix the fetch.
The tempting select fix has a trap
select {
case data <- store.Fetch():
case <-ctx.Done():
return
}This gives the send an escape route, but it does not interrupt Fetch. Go evaluates the send value before choosing a case. Even an already canceled context cannot prevent this call from starting. See the select evaluation rules in the Go specification.
You can reproduce the distinction without any network call:
ctx, cancel := context.WithCancel(context.Background())
cancel()
var ch chan string // nil: sending can never proceed
called := false
select {
case ch <- func() string { called = true; return "value" }():
case <-ctx.Done():
}
fmt.Println(called) // trueFor one HTTP dependency, start with a direct call
A handler can block while a context-aware HTTP client waits. It does not need an extra worker and a result channel just to make that wait cancellable. Pass the request context into the outgoing request:
func fetch(ctx context.Context, client *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("upstream returned %s", resp.Status)
}
const limit = 1 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return "", err
}
if len(body) > limit {
return "", errors.New("upstream response exceeds 1 MiB")
}
return string(body), nil
}NewRequestWithContext connects cancellation to the standard client's request lifetime, including reading the response body. Close the body and bound how much you read. The example rejects non-200 responses and bodies larger than 1 MiB.
func handler(client *http.Client, url string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
body, err := fetch(ctx, client, url)
if err != nil {
if r.Context().Err() != nil {
return // The caller no longer needs a response.
}
status := http.StatusBadGateway
if errors.Is(err, context.DeadlineExceeded) {
status = http.StatusGatewayTimeout
}
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, body)
}
}The two-second deadline belongs to this dependency call. An upstream failure produces 502, while this deadline expiring produces 504. If the incoming request is already canceled, the example returns without trying to deliver a response.
The upstream server in the downloadable demo also observes its request context. A real remote service might keep computing after the client disconnects. Client cancellation is not proof that a remote job stopped or a write rolled back; design those guarantees separately.
When you really need concurrent workers
If several dependencies must run at once, pass the same request-derived context into each operation, then protect result delivery too:
// Schematic: fetch must itself honor ctx.
go func() {
value, err := fetch(ctx, client, url)
select {
case results <- result{value: value, err: err}:
case <-ctx.Done():
return
}
}()A capacity-one result channel can let one producer deliver one result after its receiver leaves. That narrowly fixes the blocked send. It cannot stop a Fetch that ignores cancellation, and it does not solve arbitrary numbers of abandoned sends. This is the same ownership problem discussed in Go's pipeline cancellation article.
Do not close a results channel from the receiver while workers might still send. Arrange for a coordinator to close it after all senders finish, if closing is needed.
Cancellation and completion are different events
cancel() requests shutdown; it does not wait for shutdown. If resource cleanup requires the worker to have exited, wait for an explicit completion channel or a WaitGroup. The CancelFunc documentation makes this distinction explicit.
For CPU-heavy loops, check cancellation at useful intervals. For database calls, use the context-aware query API and understand the driver's behavior. Wrapping an uninterruptible call in a goroutine only moves the wait somewhere else.
Run the example and verify the exit path
Download these three files into an empty directory. They use only the standard library and create local test servers; no external API is needed.
go test -race -v
go run .
# request deadline exceeded: trueThe four tests cover cancellation reaching the upstream handler, a successful response, select's send-value evaluation, and an abandoned send taking its cancellation exit. The propagation test waits for the upstream to start before canceling, then waits for it to stop. It does not infer success from a sleep.
Verified locally with Go 1.19.3 on macOS. This older toolchain required -ldflags=-linkmode=external on the host; all four tests passed with the race detector. That flag is a local linker workaround, not part of the cancellation technique.
Use this when reviewing a goroutine
- Who owns its lifetime?
- Can the blocking operation observe cancellation?
- What happens if nobody receives its result?
- How will the owner know it has actually exited?
Those questions expose more bugs than adding another select around an operation that never sees the context.
Written by Backend Interview Lab. This guide and its code are free. For related explanations, browse the free Go interview questions. Our paid runtime and concurrency playbook is optional.