Your pager fires at 3 a.m. Memory is climbing, and go tool pprof shows a goroutine count that never comes back down. You stare at the flame graph, find the fat stack, and… nothing. The graph tells you where the goroutines are parked, not why they never finish. Same story with the race detector: two stacks, a red warning, and a vague feeling that someone, somewhere, forgot to close a channel.
That gap — between a symptom and a cause — is exactly where an LLM earns its keep. Not as an oracle that "fixes your concurrency," but as a tireless reader of dumps, stack traces, and diffs that asks the same boring questions every time: who owns this channel, who cancels this context, who calls Done().
Below are twelve prompts I actually reuse. Each one is written to be pasted into a chat with a model that has your code or your output in front of it. None of them replace the race detector or pprof — they sit on top of them.
First, the uncomfortable truth about these tools
go test -race and the race detector catch races that actually happen during the run. A race in a code path your tests never hit is invisible. And pprof's goroutine profile is a snapshot — it shows you the current state, not the history of how you got there.
So the real workflow looks like this: the tool gives you evidence, you form a hypothesis, and the LLM helps you stress-test that hypothesis against the code. If you hand a model a stack trace and ask "what's wrong?", you get plausible-sounding fiction. If you hand it the ownership rules — who closes what, who waits on what — you get something you can verify.
Reading the evidence: three prompts for pprof and the race report
1. Turn a goroutine dump into a ranked suspect list
A raw pprof goroutine profile in debug=2 mode is thousands of lines. The useful signal is the count per stack — the same function parked 4,000 times is a different disease than one goroutine stuck forever. This prompt makes the model do the grouping you'd otherwise do by hand.
Prompt:
Here is a full goroutine dump from runtime/pprof (debug=2). Group identical stacks, sort groups by count, and for each group tell me: (1) the state (chan receive, select, IO wait, semacquire), (2) the most likely owner of the resource it is waiting on, (3) whether the count looks like a leak (grows with traffic) or a fixed pool (bounded by GOMAXPROCS or a worker count). Output a table with columns: stack signature
| count | state | leak or pool | what to check next. Do not propose fixes yet.
Why it works: the state field (chan receive vs select) narrows the cause fast. A pile of goroutines in chan receive on the same channel usually means nobody closes it or the consumer died. A pile in select means they're waiting on a context that never gets cancelled.
Failure mode to watch: if you paste a dump from a health check where the count is naturally high, the model will happily call it a leak. Always include two dumps taken minutes apart, and tell the model which is which.
2. Translate a race detector report into a sequence of events
The race detector gives you two conflicting accesses with stacks. What it does not give you is the interleaving that made them conflict — that's the part you have to reconstruct, and it's where people stall.
Prompt:
Here is a DATA RACE report from `go test -race`. For each of the two accesses, tell me: which goroutine, whether it is a read or write, and what synchronization (if any) the code relies on at that point. Then describe the minimal interleaving that produces the race, as a numbered sequence of events on two goroutines. Finally, name the primitive that would have prevented it: mutex, atomic, channel handoff, or copy-on-write — and justify why that one and not the others.
Example of what you get back: "G1 writes cfg.Timeout during init; G2 reads it inside a request handler 40ms later; there is no happens-before edge because the handler was started before init returned." That last clause is the sentence you'd otherwise spend an hour arriving at.
3. Decide whether the race is benign (it isn't — but here's how to prove it)
Every team has one person who says "that race is fine, it's just a counter." Make the model argue the other side with your code as evidence.
Prompt:
Here is the code around a reported race on a shared variable. Assume the race is real and harmful. Enumerate every way the missing happens-before edge can corrupt state: torn reads on 64-bit values on 32-bit platforms, lost increments, stale cache, reordering visible to a third goroutine. For each, give a concrete input or timing that triggers it. Then show the smallest diff that removes the race without changing behaviour. For a deeper dive into this topic, see [Serverless Architecture](/en/blog/serverless-architecture-where-lambda-and-cloud-functions-break-and-how-to-design-around-it).
The non-obvious part: on a 64-bit build a int64 counter race may look harmless because aligned loads are atomic in practice on amd64 — but that's an implementation detail, not a language guarantee, and the compiler is free to hoist the load out of a loop. The model will usually surface that hoisting case if you ask for "reordering visible to a third goroutine."
Ownership audit: the prompts that find the missing close
Most goroutine leaks are not clever. They're a channel nobody closes, a context nobody cancels, or a WaitGroup where Add and Done don't balance. These four prompts check the contract directly.
4. Trace every channel's lifecycle
Prompt:
For every channel created in this file, list: where it is created, who sends, who receives, who closes it, and under what condition. Flag any channel that is created but never closed, closed by a sender that isn't the sole sender, or received from after close without a `, ok` check. Output as a table plus a list of violations.
Why it matters: "close by the sender, never the receiver" is a rule everyone knows and everyone breaks in the error path. The model catches the error path because it reads the whole function, not just the happy path you remembered when you wrote it.
5. Check context cancellation end to end
Prompt:
Here is a request handler and the goroutines it spawns. Build a chain: which context is passed to which function, where it is derived with `WithCancel`/`WithTimeout`, and where `cancel()` is called. Flag any derived context whose cancel function is not called on every return path, and any goroutine started with `context.Background()` instead of the request context. Show the corrected version.
A classic catch: ctx, cancel := context.WithTimeout(...) followed by defer cancel() — fine — but then a goroutine spawned with go worker(ctx) that outlives the handler and gets a cancelled context it never checks. The leak isn't the context; it's the goroutine that ignores ctx.Done().
6. Balance the WaitGroup
Prompt:
Find every `sync.WaitGroup` in this package. For each, show the `Add`, `Done`, and `Wait` calls with their line numbers. Verify that every `Add` has a matching `Done` on all paths including panics and early returns, and that `Wait` is not called concurrently with `Add`. Point out any place where `Add` is called inside the goroutine instead of before it.
That last condition is the one that produces the flaky "WaitGroup is reused before previous Wait has returned" panic — and it's nearly impossible to spot by eye in a 400-line file.
From symptom to fix: three prompts for the hard cases
7. Build a minimal reproduction
Once you suspect a leak, you need a test that fails without the fix. This prompt turns a hypothesis into a runnable test.
Prompt:
Based on this function and the suspected leak (goroutines in `chan receive` on `jobs`), write a Go test that starts N goroutines, sends M jobs, closes the input, and then asserts with `runtime.NumGoroutine()` that the count returns to baseline within 2 seconds. Use a helper that polls instead of a fixed sleep. The test must fail on the current code and pass after the fix.
Trade-off: runtime.NumGoroutine() is a blunt instrument — background runtime goroutines and test-parallelism noise make it flaky if you assert an exact number. Assert <= baseline + tolerance and poll, or use goleak-style verification in TestMain. The model will usually give you the naive version unless you ask for polling explicitly.
8. Stress the fix before you ship it
Prompt:
Here is my proposed fix (close `jobs` in the producer, add `wg.Wait()` before return). Attack it. List every scenario where this fix still leaks or deadlocks: producer panics before close, consumer returns early, multiple producers, channel buffer full and consumer blocked, context cancelled mid-send. For each, say whether the current fix handles it and what to add.
This is the single most valuable prompt in the set. It turns the model from a code generator into a reviewer that assumes you got it wrong — which, statistically, you did on the first try.
9. Design the observability you're missing
You can't fix what you can't see. If the leak only shows up in prod, the fix is partly instrumentation.
Prompt:
I want to catch goroutine leaks in production before they OOM the pod. Propose a minimal set of metrics and a pprof endpoint strategy: which runtime metrics to export (`go_goroutines`, `go_threads`, heap inuse), what alert threshold makes sense relative to a steady-state baseline, and how to capture a goroutine dump on demand without restarting. Include the `net/http/pprof` wiring and a note on securing it.
Where this breaks: net/http/pprof on a public port is a gift to attackers — it exposes stack traces and heap contents. Put it on a separate internal listener or behind auth. The model will mention this if you ask about securing it; it won't if you don't.
The traps that make LLM advice worse, not better
Three failure modes I've hit repeatedly, and how each prompt above is designed to avoid them.
| Trap | What happens | How to counter it |
|---|---|---|
| Symptom-only prompt | Model invents a plausible cause | Always paste the owning code or full stack |
| Stale dump | Model calls a healthy pool a leak | Send two dumps with timestamps |
| "Just add a mutex" | Fix hides the race, adds contention | Ask for the smallest diff and the trade-off |
10. Ask for the trade-off, not just the fix
Prompt:
You suggested wrapping this map access in a `sync.RWMutex`. Compare that against `sync.Map` and against a channel-based single-writer design for this access pattern (mostly reads, rare writes, ~10k reads/sec). Give me latency, memory, and code-complexity trade-offs, and tell me which one you'd pick and why.
The honest answer is usually RWMutex for read-heavy maps, sync.Map only for write-once-read-many or disjoint key sets, and a channel when you actually need ordering. A model asked for a trade-off will say this; a model asked for "the fix" will just hand you a mutex and move on.
11. Review a concurrency diff like a senior would
Prompt:
Here is a diff that touches goroutine spawning, channel closing, and error handling. Review it as a senior Go engineer. For each hunk, state whether it introduces a new happens-before edge, removes one, or leaves it unchanged. Flag any new goroutine without a clear termination condition. Do not comment on style.
This is the prompt that fits into code review — the same discipline as catching logic bugs before the merge button. If you already use LLMs for review, this slots in as a concurrency-specific pass.
12. Write the postmortem before the incident
Prompt:
Write a short runbook for diagnosing a goroutine leak in this service: the exact pprof commands to run, what to look for in the goroutine profile, how to tell a leak from a pool, and the first three hypotheses to test. Keep it under one page so an on-call engineer can follow it at 3 a.m.
Why this one matters most: the prompts above help you diagnose. This one helps the person who gets paged when you're asleep. A runbook written while you still remember the last incident is worth more than any single fix.
What you actually walk away with
The race detector and pprof are not going to get smarter. They will keep telling you where and when, and leaving why to you. The twelve prompts above don't change that — they change how fast you close the gap, by forcing the model to reason about ownership instead of pattern-matching on a stack trace.
The pattern to remember: symptom + owning code + a request for the trade-off. Skip any of the three and you get confident fiction. Keep all three and you get a diagnosis you can test with go test -race -count=10 before it ever reaches production.
Start with prompt 8 — attack your own fix. It's the one that catches the leak you were about to ship.