AlgoMaster Logo

Goroutine Leaks

High Priority16 min readUpdated June 6, 2026

A goroutine leak is a goroutine that should have exited but didn't. It's still alive, holding stack memory and a slot in the scheduler, waiting on something that will never happen. One leaked goroutine isn't a problem. Ten thousand of them, accumulating over weeks of uptime, is how long-running Go servers crash with mysterious memory-growth charts.

What a Leak Looks Like

A normal goroutine starts, does its work, and returns. The runtime reclaims its stack and the scheduler frees the slot.

A leaked goroutine starts, does some of its work, and then gets stuck. It never reaches its return. The runtime can't reclaim anything because, from the runtime's perspective, the goroutine is still doing something. It might be holding a lock, sleeping in an infinite loop, or waiting on a synchronization primitive that no one will ever signal.

Here's a tiny program that leaks one goroutine every time main runs:

leakOne returns cleanly. main returns cleanly. But the goroutine launched inside leakOne is still parked, waiting for a wg.Done() that no one will ever call. When main exits the program ends and the OS reclaims everything, so the leak is invisible here. In a long-running server, that goroutine stays stuck until the process restarts.

Why Leaks Matter in Long-Running Servers

Short-lived programs hide leaks. A command-line tool that runs for two seconds and exits never lives long enough for leaked goroutines to matter. The OS cleans up on process exit.

Servers are the opposite. An HTTP server, a background worker, an order-processing daemon, they all stay up for days, weeks, or months. Every leak compounds. A handler that leaks one goroutine per request and handles 100 requests per second is leaking 8.6 million goroutines per day. The process will run out of memory long before that.

The pattern most teams notice first is a slow, monotonic increase in memory usage. The process starts at 200 MB, climbs to 500 MB over a day, then 1 GB the next day. Nothing in the application logic explains it. The GC is running. Allocations look normal. It turns out the actual problem is goroutines that started weeks ago and never exited.

A leak isn't always slow. A bug that spawns a goroutine inside a tight loop without a stop signal can leak thousands per second. That's the fast-fail case, easy to spot. The slow-leak case, one goroutine per hour, is the dangerous one because it survives staging tests and only surfaces in production after weeks of uptime.

Cause 1: Infinite Loop Without an Exit Condition

The simplest leak is a goroutine running for { } with no break, no return, and no way for the outside world to tell it to stop.

Five poller goroutines, plus the main goroutine. The pollers will run forever. There's no way to stop them short of killing the process. If pollStockPrice were called once per customer session and sessions came and went all day, the count would only ever grow.

The fix is to give the loop an exit. A boolean flag the goroutine checks each iteration is the simplest version. sync/atomic makes that flag safe to read from one goroutine and write from another without a race.

Each poller checks the flag at the top of every iteration. When stop.Store(true) runs, the next loop turn sees it and the goroutine returns. The "after stop" count drops back to one (just main).

The atomic flag works for simple cases. The production answer is context.Context, which carries cancellation through a call tree cleanly. We'll show the context shape later in this chapter because it's what real code uses.

Cause 2: Blocking on a Synchronization Primitive That's Never Signaled

The second common cause is a goroutine blocked on a sync primitive whose unblock condition never happens. The classic version is a sync.WaitGroup whose Done call gets skipped.

Four orders, two of them (IDs 2 and 4) skip the wg.Done() call. wg.Wait() in the reporter goroutine waits for the counter to reach zero, but it never will. That reporter goroutine is leaked, and so is anything else downstream that depends on it.

The fix is the rule: defer wg.Done() at the top of the function. defer runs even when the function returns early or panics, so the counter always decreases.

Same setup, different placement of wg.Done. Now every order path decrements the counter and the reporter goroutine finishes.

A related shape is a sync.Mutex that gets locked but never unlocked. The next goroutine that tries to Lock will block forever. Same fix applies: defer mu.Unlock() immediately after mu.Lock() so the lock is always released, even on panic.

The bad version doesn't leak a goroutine by itself, but the moment anything else calls decrement or any other method that acquires s.mu, that goroutine blocks forever. One missed Unlock is enough to leak every goroutine that ever touches the lock afterwards.

Channel-receive blocks are the most famous version of this pattern. A goroutine waiting to receive on a channel that no one will ever send to is permanently parked. The principle is the same: any blocking operation needs a guarantee that the unblock event will happen, or a timeout that frees the goroutine when it doesn't.

Cause 3: Long Work Without a Cancellation Path

A request comes in. The handler starts a goroutine to fetch some data. The user closes the browser. The handler hits its timeout and returns an error to nobody. The goroutine, meanwhile, is still mid-call to a slow database.

This is the cancellation gap. The caller has given up, but the work it spawned keeps going. If those goroutines pile up faster than they finish, you have a leak even though each one will eventually exit.

Each handleRequest call leaves behind a goroutine that's stuck inside fetchCustomerReviews. The handler "returned" from the user's perspective, but the work goroutine has no idea the caller doesn't care anymore. Ten requests means ten leaked goroutines, each holding the full call stack for the fetch.

The Go standard answer for this is context.Context. The handler creates a context with a timeout, passes it to the worker, and the worker checks the context periodically (or hands it to any function that supports cancellation, like http.Client.Do or database/sql.QueryContext). When the timeout fires, the context is cancelled and the worker can return immediately.

Now when the timeout fires, the context is cancelled and the fetch goroutine returns on the next ctx.Err() check. No leaks. The structural fix is the same regardless of polling style: every long-running operation needs a way to be told "stop early".

Cause 4: Waiting for a Result That Will Never Come

The flip side of the cancellation gap is a goroutine that's waiting on a result from another goroutine, and the producer was cancelled or never started.

A common shape: a goroutine launches a helper to fetch some data, then sleeps until the helper signals "done". If the helper exits early because of an error, and the original goroutine isn't told, it sleeps forever.

The producer exits without ever setting the result. The consumer keeps polling and will keep polling for as long as the process runs. The leak isn't a stuck primitive, it's a polling loop with no exit. Same family of bug as Cause 1, but disguised as a coordination pattern.

The fix is to make the "I gave up" case explicit. The producer should signal failure as clearly as it signals success, so the consumer has something to react to. The pattern most production code uses pairs the result with a status:

And the consumer's wait loop checks both ready and failed. With channels this becomes a one-line select, which is why channels are the idiomatic answer in Go. The principle holds regardless of the mechanism: every wait needs at least two ways out.

Detection: Watching runtime.NumGoroutine

The fastest way to confirm you have a leak is to count goroutines over time. runtime.NumGoroutine() returns the current count. Sample it periodically and watch the trend.

The count climbs by 10 every round and never comes down. That's the signature of a leak. If you log this number from a production server every minute, a leak shows up as a slow, monotonic climb on the chart. Spiky behavior that climbs and falls is normal load. Monotonic growth that doesn't track request traffic is a leak.

A healthy server has a goroutine count that oscillates around some baseline tied to active connections or work in flight. Sudden growth, or growth that doesn't recede during quiet periods, is what you're looking for.

For finding which goroutine is leaking, runtime/pprof exposes a goroutine profile that shows every live goroutine and its stack. You can dump it on demand or expose it via the net/http/pprof handler. The output tells you exactly where each goroutine is parked, which usually points straight to the bug.

Prevention Patterns

Most leaks come from the same handful of mistakes. A few habits, applied consistently, prevent almost all of them.

Give every long-running goroutine a way to stop. A context.Context is the cleanest version. An atomic flag works for simple in-process cases. Something outside the goroutine must be able to tell it "you're done, please exit". A goroutine with no exit signal is a leak waiting for the right conditions.

Pair every "start" with a "stop". If a function starts a goroutine, the same function (or its caller) should know how to stop it. This usually means returning a cancel function, accepting a context.Context, or providing a Close method on the object that owns the goroutine. If your code starts background work without a corresponding shutdown path, that's a leak in waiting.

Use `defer` for cleanup of locks and counters. defer wg.Done() at the top of every goroutine function. defer mu.Unlock() right after mu.Lock(). defer cancel() right after creating a context with a timeout. defer runs even when the function returns early or panics, which is exactly when the cleanup matters most. The exception is when you genuinely need to release the lock before the function returns, in which case keep the manual Unlock but be extra careful about every return path.

Set timeouts on anything that waits for external resources. HTTP calls, database queries, downstream RPCs. None of them should block a goroutine indefinitely. The context package's WithTimeout and WithDeadline are designed exactly for this. A goroutine that's waiting on a slow database with no timeout is a goroutine that can become a leak the moment the database goes down.

Treat goroutine count as a metric, not a debugging tool. Log runtime.NumGoroutine() from production, alert on monotonic growth. A leak caught by an alert at 500 goroutines is a much easier debug than the same leak found at 50,000 because the server fell over.

None of these are exotic. They're the boring habits that make Go services stay up for months.

A Larger Example: The Stock Price Poller

Putting the patterns together, here's a more realistic version of the stock-price-poller scenario from the chapter intro. The site has customer sessions. Each session wants the latest price of the products in the customer's cart, polled once per second. When the session ends, the polling must stop.

100 sessions, each with 3 pollers, gives 300 goroutines plus the main one. After every session is closed, the count drops back to 1. No leaks.

The pieces that matter:

  • Each session owns a context.Context and a cancel function. The pollers receive the context.
  • Each poller selects on ctx.Done() every tick. When the context is cancelled, the poller exits.
  • The session's WaitGroup tracks every poller it started. Close cancels the context, then waits for every poller to return before considering the session shut down.
  • defer wg.Done() at the top of pollPrice ensures the counter decreases even if the function panics.

The diagram shows the lifecycle. main calls NewSession which spawns three pollers. When Session.Close runs, it cancels the context, every poller's select fires on ctx.Done(), each poller returns, and the WaitGroup unblocks Close. The session is now fully shut down with no orphaned goroutines.

Compare this to the original bug: 10,000 sessions, each spawning a poller that loops forever. After a day of normal traffic, that process is sitting on hundreds of thousands of leaked goroutines, all polling something nobody is listening to anymore. Same business logic, missing the cleanup machinery.

Quiz

Goroutine Leaks Quiz

10 quizzes