🕵️ goleaked
Point it at a running Go process, catch the goroutines that never finish

A dependency-free CLI that attaches to a running Go process over net/http/pprof, samples its
goroutines repeatedly on a timer, and flags the stack traces that keep recurring, sample after
sample, without ever finishing. That recurrence, not a single snapshot, is the actual signature
of a goroutine leak.
$ goleaked -url http://localhost:6060/debug/pprof/goroutine?debug=2 -duration 5m -interval 10s
goleaked: sampled 30 times over 5m0s (interval 10s)
found 1 possible leak(s):
[1] state: chan receive, 4 minutes
main.handleUpload()
/app/upload.go:42 +0x30
created by main.main
/app/main.go:88 +0x24
What it is
A small, single-binary pipeline with four stages: fetch a goroutine dump over HTTP from a
target process's pprof endpoint, parse the raw text into structs, sample it repeatedly on
a timer, and diff the accumulated samples by hashing each goroutine's state + stack into a
signature and counting how many distinct samples each signature shows up in. A signature present
in a high enough fraction of samples (configurable, not hardcoded) gets flagged as a likely leak.
An allowlist filters out stacks you already know are supposed to run forever (an HTTP server's own
accept loop, for instance), so a legitimately permanent background goroutine doesn't get flagged
alongside an actual bug.
What it solves
Go's built-in tools give you the pieces, but not this specific answer, without extra effort:
go tool pprof / a single curl of /debug/pprof/goroutine gets you one snapshot. A
snapshot alone can't tell you whether a stuck goroutine is a permanent, healthy part of the
program or an actual leak that's about to take the process down: you'd have to pull several
dumps by hand and diff them yourself to find out.
uber-go/goleak, the well-known library in this space,
solves a different problem: it's a single before/after comparison run inside a test, asserting
no unexpected goroutine survived past t.Cleanup. It doesn't watch a live, running process over
time.
- Continuous profiling platforms (Grafana Pyroscope, Google Cloud Profiler, APM vendor
add-ons) do sample a live process over time, but they're built for a human to browse a flame
graph and notice something looks wrong, not to automatically hash-and-diff recurring stacks
and hand back a yes/no list of suspects.
goleaked sits in the gap: point it at a suspicious process for a few minutes and get a short,
concrete list of candidates, without wiring up a test or standing up an observability platform.
How it works
| stage |
package |
does |
| fetch |
internal/pprofclient |
HTTP GET the pprof goroutine dump (?debug=2) from a target URL |
| parse |
internal/parse |
turn the raw text dump into []Goroutine{ID, State, Stack} structs |
| sample |
internal/sampler |
poll fetch+parse on a timer, accumulate timestamped samples behind a mutex |
| diff |
internal/leak |
hash each goroutine's state+stack into a signature, count which signatures recur across samples, filter via an allowlist, flag anything past a threshold |
Correctness has been verified against testtarget/, a small local server with two goroutines
deliberately stuck forever, confirming goleaked finds exactly those two and nothing else.
Install
go install github.com/Aparajith24/goleaked@latest
Or build from source:
git clone https://github.com/Aparajith24/goleaked.git
cd goleaked
go build -o goleaked .
./goleaked -url http://localhost:6060/debug/pprof/goroutine?debug=2
Or run it directly without a separate build step:
go run . -url http://localhost:6060/debug/pprof/goroutine?debug=2
Usage
goleaked [flags]
-url string
pprof goroutine dump URL (default "http://localhost:6060/debug/pprof/goroutine?debug=2")
-interval duration
how often to sample (default 1s)
-duration duration
how long to sample for (default 5s)
-threshold float
fraction of samples a signature must appear in to be flagged, 0-1 (default 1)
-ignore string
comma-separated substrings of stacks to ignore (default "net/http.(*Server).Serve")
The target process just needs _ "net/http/pprof" blank-imported and an HTTP server running
somewhere reachable, nothing else to install on the target side.
Who this is for
- You have (or suspect) a goroutine leak in a running Go service and want a quick, concrete list
of candidate stacks, without writing a test or reaching for a full profiling platform.
- You're comfortable running a short-lived CLI against a process's pprof endpoint for a few
minutes and reading a stack trace to figure out the fix yourself.
goleaked finds candidates,
it doesn't fix anything or explain why a stack is stuck.
What this is not
- Not a replacement for
uber-go/goleak in tests. If you want a CI-enforced "no leaked
goroutines" assertion at the end of a test run, that's a different, already-solved problem:
use goleak.
- Not a production monitoring / always-on tool. There's no persistence, no alerting, no
dashboard, no long-term storage of samples across runs. It's a point-in-time diagnostic CLI you
run when you already suspect a problem, not something you'd leave running against production
indefinitely.
- Not a fix, and not root-cause analysis. It tells you which stack keeps recurring, not
why; that part is still on you.
- Not aware of process restarts. Goroutine IDs and all in-memory sample state are scoped to
one continuous
goleaked run against one continuously-running target process. If the target
restarts mid-run, samples from before and after the restart get compared as if they were the
same process.
Known limitations
- The allowlist is a plain substring match (
strings.Contains) against the full signature
text, not a structured pattern language. Effective for filtering out known infrastructure
frames like an HTTP server's accept loop, but easy to over-match if a pattern happens to appear
in an unrelated stack too.
- A goroutine that's merely slow, not leaked, can still get flagged if it happens to be
running long enough to span every sample in a short observation window (e.g.
-duration 5s with
a task that legitimately takes 6s). Longer -duration values relative to your program's normal
worst-case latency reduce this.
- No goroutine count/growth tracking. A signature that shows one stuck instance across every
sample and a signature whose instance count is climbing every sample look the same to the
current threshold logic: both just get counted as "present in this sample or not." Distinguishing
a single permanently-stuck worker from an actively growing leak is a natural next step.
Built to learn Go
goleaked was built as a hands-on way to actually learn Go: goroutines, channels,
sync.Mutex/sync.WaitGroup, the runtime scheduler, and net/http/pprof, not just to end up
with a working binary. Each piece was worked through by hand before moving to the next, most
notably the concurrency layer: deliberately reproducing a data race with go run -race, fixing it
with sync.WaitGroup and sync.Mutex, then carrying that same discipline into the sampling loop,
where a background goroutine polls on a timer while the main program reads accumulated results
safely through a mutex. Parsing the raw pprof dump was written by hand with strings/bufio
rather than reached for via a regex library, to actually understand what a parser does. The
signature-and-recurrence logic, threshold, and allowlist all came out of watching a real false
positive happen (an HTTP server's own accept loop getting flagged as a "leak") and fixing it, not
from designing it upfront.
License
MIT, see LICENSE.