Count Goroutines at Runtime and Watch for Slow Growth
Count goroutines with runtime.NumGoroutine or runtime/metrics, export the number from a service, and read a steady climb as the earliest sign of a leak.

The quickest health check for a concurrent Go program is one number: how many goroutines exist right now. It costs almost nothing to read, and a graph of it over a day tells you more about leaks than any amount of staring at code. This note shows how to read it, how to export it, and what the shape of the graph means.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It is a companion to goroutine leaks and detecting leaks in tests, and it belongs to the concurrency material. Everything ran on Go 1.25.6.
Reading the count
package main
import (
"fmt"
"runtime"
"runtime/metrics"
"sync"
"time"
)
func goroutinesMetric() uint64 {
sample := []metrics.Sample{{Name: "/sched/goroutines:goroutines"}}
metrics.Read(sample)
return sample[0].Value.Uint64()
}
func main() {
fmt.Println("at start:", runtime.NumGoroutine())
release := make(chan struct{})
var wg sync.WaitGroup
for range 50 {
wg.Go(func() { <-release })
}
time.Sleep(10 * time.Millisecond)
fmt.Println("with 50 workers:", runtime.NumGoroutine(), "| metric:", goroutinesMetric())
close(release)
wg.Wait()
time.Sleep(10 * time.Millisecond)
fmt.Println("after they finish:", runtime.NumGoroutine())
}at start: 1
with 50 workers: 51 | metric: 51
after they finish: 1At the start there is one goroutine, main. With fifty workers blocked the count is 51, and both runtime.NumGoroutine() and the runtime/metrics reading agree. After the workers finish, the count returns to 1. That return is the behaviour of a healthy program: the number rises while work is in flight and falls back when it is done. Any code that leaves the count higher than it started has left goroutines behind.
Note the details. The count includes the calling goroutine and every goroutine the program started, and also some the runtime owns; the exact baseline of a trivial program can differ between Go versions, so compare against your own baseline, not against a fixed number. It is a snapshot, so a spike for a moment is normal.
Exporting it from a service
A service should export the count as a metric. The Prometheus Go client's default collector exposes go_goroutines, and OpenTelemetry's runtime instrumentation exposes a goroutine count too, so if you already collect runtime metrics you likely have it. The standard library alternative is expvar, which serves JSON on /debug/vars, and the net/http/pprof handler shows the count at the top of /debug/pprof/goroutine?debug=1. For a quick log line, a ticker works:
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for range t.C {
slog.Info("runtime", "goroutines", runtime.NumGoroutine())
}
}()(That goroutine needs a way to stop in a real program, for the reason this whole series keeps stating.)
Reading the graph
The shape tells you what is happening.
| Shape | Likely meaning |
|---|---|
| Rises and falls with traffic | Healthy: goroutines per request or per connection |
| Flat line | A fixed pool; good, if it is the pool size |
| Slow steady ramp, ignoring traffic | A leak of one goroutine per some event |
| Staircase that steps up at certain times | A leak tied to a periodic job or a deploy |
| Sudden spike that decays | Burst of work, or a retry storm that cleared |
| Spike that stays | A burst that leaked or a dependency that hung |
Compare with request rate on the same dashboard. A service that handles twice the traffic at noon should show more goroutines at noon; a count that is higher every day at the same time of day, with the same traffic, is leaking. Leaks are slow, so look at a window of hours or days, and set the alert on the slope (increase over six hours) and not a threshold.
The count says that, the profile says where
When the graph says leak, the next step is the goroutine profile: /debug/pprof/goroutine?debug=1 groups goroutines by stack with counts, and the stack with the biggest count and a blocked state is the leak (goroutine leaks shows how to read it). Take two snapshots an hour apart and compare: the stack whose count grew is the one. In CI, a leak check on every package that starts goroutines catches the rest. The reference is the runtime.NumGoroutine documentation and the list of supported metrics in runtime/metrics.


