Go's context.WithTimeout Discarded Cancel: The Timer Heap That Grows Under Load

2026-08-19

This function fetches a stock quote with a 2-second timeout. It's called from a hot loop serving ~500 requests per second, and most requests complete in about 50 ms. The code passes tests, passes review, and ships to prod. Two weeks later, on-call gets paged: memory usage has climbed 3 GB overnight on the quote service, and runtime.NumGoroutine() keeps drifting up. GC frees very little. What's wrong?

package main

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

// fetchQuote retrieves a stock quote with a 2-second timeout.
func fetchQuote(symbol string) (string, error) {
    ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)

    req, err := http.NewRequestWithContext(ctx, "GET",
        "https://api.example.com/quote/"+symbol, nil)
    if err != nil {
        return "", err
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    return string(body), err
}

The Bug

The cancel function returned by context.WithTimeout is thrown away with _. That single underscore is the leak.

Under the hood, WithTimeout schedules an internal timer that fires cancel at the deadline, and it registers the new context in its parent's children map so cancellation can propagate. Calling cancel yourself does two things: it stops the timer early, and it removes the child from the parent's map, releasing any goroutines waiting on ctx.Done().

When you discard cancel, neither happens until the 2-second timer eventually fires on its own — even if the HTTP request finished in 50 ms and you've long moved on. The context, its timer, its Done channel, and the closure references they hold all stay alive.

Do the math on a hot path: at 500 rps × 2 s of retention, you have roughly 1000 pending contexts and their timer entries live at any moment. Under sustained load, the runtime's timer heap balloons, GC pressure rises, and pprof shows growing allocations rooted in context.propagateCancel. It's not a permanent leak — everything eventually clears — but it's a live leak proportional to (rate × timeout), which is exactly the shape that looks fine in a unit test and destroys you in production.

What makes this especially insidious: because parent is context.Background() (which is never canceled), there is nothing upstream that will ever prune the child map early. Every discarded cancel just sits there ticking.

The Fix

Always capture cancel and defer it. This works correctly on both the fast path (request finishes early) and the slow path (deadline fires first — calling cancel on an already-canceled context is a documented no-op):

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

go vet ships with a lostcancel check that catches this exact pattern. If you're not running vet in CI as a build gate, you're leaving free bug-finding on the table. The linter staticcheck flags it too (SA1029/SA5001-adjacent rules).

One subtle point worth internalizing: defer cancel() is not just about the error path. Even on the happy path, where the HTTP call succeeds in 50 ms, you need that cancel to detach the context and stop the timer now rather than 1950 ms from now. The bug isn't hidden in an error branch — it fires on every single successful request.

Key Takeaway: The cancel from context.WithTimeout isn't optional cleanup — discarding it retains the context, its timer, and its parent linkage until the deadline fires, turning every fast request into a live leak until the clock catches up.

All newsletters