golang.
golang8 min read

Context Cancellation and Goroutine Leaks in Go

Context Cancellation and Goroutine Leaks in Go

Context Cancellation and Goroutine Leaks in Go

A few months back I helped a teammate debug a Go service that was eating about 2 GB of memory a day. We finally pulled a goroutine dump at 3 in the morning and found roughly 14,000 of them blocked on the same channel send — every one traceable to a feature we'd shipped six weeks earlier. That bug is why this intro exists.

Goroutine leaks happen because spawning is cheap and forgetting is easy. The context package is what Go gives you to make forgetting harder — a shared protocol for "stop what you're doing, we're done here." This piece isn't a tutorial; it's a pointer. By the end you'll know which docs to read next, which patterns to copy, and which mistakes to expect when you start wiring cancellation into your own code.

What a goroutine leak feels like

Why doesn't Go's garbage collector clean up a goroutine that's blocked forever? It can't — and that single fact is what makes the leak count so much harder to read than a memory profile. The Go runtime doesn't garbage collect goroutines; the only way a goroutine ends is for its top-level function to return. If that function is blocked forever — waiting on a channel that nobody will ever send to, sleeping in a select with no ready case, stuck in a Read on a connection whose peer vanished — the goroutine is now a permanent resident of your process.

The symptoms creep in. Memory usage rises in a sawtooth pattern that looks superficially like a normal GC trace but trends upward over hours. The number of goroutines reported by runtime.NumGoroutine() climbs steadily. Eventually you hit file descriptor limits or thread limits, because every blocked network read is sitting on a socket, and every socket is a file descriptor. The crash, when it comes, is rarely informative. You get accept: too many open files or runtime: program exceeds 10000-thread limit, and the root cause is some forgotten go statement from a feature you shipped three sprints ago.

What makes leaks especially painful is that they pass tests. Unit tests run for milliseconds and exit before the leak matters. Integration tests usually spin up fresh processes per case. You see the bug only under sustained load, in production, with no easy reproduction. That's why the leak-prevention discipline has to live in the code itself, not in CI.

Why context exists

In the Go 1.6 days I watched a team spend a full sprint reconciling four different cancellation conventions across three internal libraries, just to make a graceful shutdown work end-to-end. The context package is what the Go team shipped in 1.7 so that nobody would ever have to do that again. Some used a quit chan struct{} passed by value; some used a global sync.Cond; some did nothing and hoped. The result was that no two libraries spoke the same shutdown protocol, and composing them meant gluing cancellation paths together by hand.

The context package, documented at pkg.go.dev/context, gives that protocol a shared shape. A Context is an interface with four methods (Deadline, Done, Err, and Value), and the only one that really matters for cancellation is Done(), which returns a channel that the context closes when it's cancelled or expires. Any goroutine that selects on ctx.Done() wakes up the instant the context is cancelled, regardless of who cancelled it or why.

The Go blog's classic post Go Concurrency Patterns: Context lays out the design philosophy. The key invariant is that a Context flows down the call stack: a server accepts a request, creates a root context, derives child contexts for downstream calls (database queries, RPC fan-outs, cache lookups), and every derived context inherits cancellation from its parent. Cancel the root and every descendant gets notified. That single guarantee is what makes structured shutdown possible.

The four derivation functions

There is no context.New() constructor in Go, and that omission is the most important design decision in the whole package. Instead you start from context.Background() (or context.TODO() if you're still deciding) and derive new contexts with one of four helpers. context.WithCancel(parent) returns a child plus a cancel func() that you must remember to call, typically with defer cancel() at the same scope where you derived. context.WithTimeout(parent, dur) does the same thing but automatically cancels after the duration elapses. context.WithDeadline(parent, time) is the absolute-time variant. context.WithValue(parent, key, val) carries a request-scoped value, not a cancellation signal, and is mostly used for things like request IDs.

Calling the cancel function isn't optional. Even if your context already cancelled because of a timeout, you should call cancel to release the resources the context holds: a small allocation plus an entry in a watchdog timer. The go vet tool warns you if you forget. This is the simplest defense:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

Without the defer cancel(), vet flags the function and you learn the discipline before the leak ever ships. The Go team has been steady about strengthening these static checks; the issue tracker on the Go GitHub repository has many threads about edge cases where cancellation propagation gets subtle, especially around HTTP client behaviour and server graceful shutdown.

Where leaks actually happen

The classic leak pattern in Go has nothing to do with malice. It looks like this: you have a function that fans out work to a goroutine and returns a channel. The caller reads from the channel, gets the first answer it cares about, and returns. The goroutine, meanwhile, is still trying to send its second value onto the channel, and now there's nobody reading. It blocks forever.

The fix is to give the goroutine an escape hatch tied to a context, and to make the channel buffered or selectable so the send never blocks indefinitely. The pattern is canonical enough that the Go blog covers it in Go Concurrency Patterns: Pipelines and cancellation, which is worth reading before you write any non-trivial pipeline of your own.

A related leak: ranging over a channel that you assumed would be closed but isn't. for v := range ch blocks forever once the producer disappears without closing. The producer has to either close the channel on its way out or arrange for the consumer to select on ctx.Done() as well.

A third leak: spawning a goroutine inside a request handler and not propagating the request's context. The handler returns, the response is sent, and that orphan goroutine keeps running — possibly indefinitely, possibly with stale state. If you genuinely need work to outlive the request, derive a fresh background context with an explicit timeout instead of silently detaching.

Detection in production

You catch leaks in production the same way you catch them in development: by watching the goroutine count over time and dumping stacks when it climbs. The net/http/pprof package, when imported for its side effect, exposes a /debug/pprof/goroutine endpoint that gives you a full goroutine dump. Hit that endpoint twice an hour apart, diff the stack traces, and the leak usually identifies itself. You'll see a thousand goroutines blocked at the same line.

For continuous observation, expose runtime.NumGoroutine() as a metric. Plot it on a dashboard. The number bounces around under normal load but shouldn't trend upward over the course of days. A steady upward slope is a leak, full stop.

There are also runtime debug flags that help. Setting GODEBUG=schedtrace=1000 prints scheduler state every second, including the number of runnable and waiting goroutines. It's noisy but illuminating during a load test.

The discipline that prevents most leaks

Three rules cover most cases. First, every function that does I/O or spawns goroutines should accept a context.Context as its first argument, named ctx. This isn't a style preference; it's the standard-library convention from net/http, database/sql, os/exec, and every gRPC stub. If your function calls into one of those packages, it already has a context-shaped hole in its signature.

Second, every goroutine you launch should have a clear termination story. Either it does a finite amount of work and returns, or it selects on ctx.Done() in every blocking operation. There's no third option. "I'll just let it run forever, it's only a few KB" is how you get to ten thousand of them.

Third, never start a goroutine in a constructor unless you're willing to expose a Close() method that stops it. The component that owns the goroutine owns the responsibility to shut it down, and the API has to surface that responsibility to the caller. Silent background goroutines are an anti-pattern; even the standard library's time.AfterFunc lets you cancel the underlying timer.

What to read next

If you're new to Go concurrency, the two blog posts linked above (Go Concurrency Patterns: Context and Pipelines and cancellation) are the canonical starting points. The package documentation at pkg.go.dev/context is short and worth reading end to end. The examples there cover most real-world usage. Beyond that, the errgroup package at pkg.go.dev/golang.org/x/sync/errgroup builds on context to give you a clean way to fan out work, collect the first error, and cancel siblings automatically. It's what you reach for when "spawn N goroutines and wait" gets old.

Goroutine leaks and context cancellation aren't advanced topics, exactly, but they're the topics where the gap between "code that compiles" and "code that survives production" is widest in Go. The patterns are small. The discipline of applying them every time is what separates services that run for months from services that quietly bloat overnight. Read the docs, wire ctx through every layer, give every goroutine an exit, and you'll spend your debugging time on more interesting bugs than this one.