"It gets slower the longer it runs" and "restarting fixes it, temporarily" are the two strongest signals of a real memory leak — state accumulating somewhere that never gets released — as opposed to a one-time high-water mark that just looks alarming on a graph. "Check for memory leaks" isn't a diagnosis; naming the specific reference chain that's holding memory alive is. One growth check and six mechanisms, ranked by how often each is the actual cause, get you there.
A memory-over-time graph that climbs once after startup and then flattens is usually cache warm-up or a one-time allocation, not a leak — chasing it as one wastes time on code that isn't the problem.
Check: get or plot memory usage over time. Only proceed as a leak hunt if the shape is unbounded growth with no ceiling, not a climb-then-plateau.
Event emitters, observers, DOM listeners, and pub/sub subscriptions get registered per-request or per-component but only cleaned up on the happy path — not on error, unmount, or disconnect. Each abandoned session leaves its listener alive forever.
Check: for every place something subscribes, confirm there's a matching unsubscribe that also runs on the error and early-exit paths, not just the success path.
A dict or map used as a cache, keyed by something unbounded — user ID, request ID, timestamp — with no TTL, no LRU, and no size cap grows forever because nothing ever removes an entry.
Check: find every long-lived cache/memoization table and confirm it has an eviction policy. If the key space is unbounded and there's no cap, it's not a cache, it's a slow-motion leak.
A callback or promise that only needed one field off a large object instead captures the whole thing via closure — a full request/response, a whole dataset — keeping that entire object alive for as long as the closure lives, which for a queued or retained callback can be indefinitely.
Check: for closures held long-term (queued callbacks, retained promises, memoized functions), confirm they close over only the specific fields they use, not the parent object wholesale.
A list or buffer that appends per event with no cap and no periodic flush is fine in a short-lived script that exits and frees everything, but in a long-running worker, server, or daemon it grows for the life of the process.
Check: for any collection fed inside a loop in a long-running process, confirm there's a cap, a periodic flush, or a bounded ring buffer — not unconditional appends.
Connections, file handles, or threads created per request without being returned to a pool or explicitly closed accumulate — especially on exception paths that skip the cleanup step entirely.
Check: confirm every acquire has a matching release in a finally/equivalent, so an exception mid-request can't skip it and leak the handle.
Module-level or global mutable state that quietly accumulates across requests is invisible in a script that runs once and exits, but in a server process that never restarts, it's a leak with the process lifetime as its only ceiling.
Check: audit module-level and global collections in server code specifically — the same pattern in a one-shot script is harmless, in a server it isn't.
Garbage-collected languages (JS, Python, Java, Go) leak via reachability — something still holds a reference to the object, not a missing free() — so frame the diagnosis as "what still points at this," not "what forgot to deallocate." Manually-managed code (C/C++, Rust unsafe) leaks via literal missed deallocation, so frame it as an allocation/deallocation mismatch instead. Either way, "a memory leak" is not a finding; "the subscriptions map in ConnectionManager is keyed by connection ID and only deleted in onClose, which never fires for connections that time out instead of closing cleanly" is.
State the growth shape, the mechanism, and the exact reference chain — then suggest the verification step, not just the fix: a heap snapshot diff across two points in time, checked against whether the retainer path for the suspected object matches what's described. Leak diagnosis from code review alone is often a well-supported hypothesis, not a confirmed finding — label it as whichever it actually is rather than implying certainty either way.
None of this requires an LLM on a small, well-understood codebase — it's one growth check and six mechanisms to rule in or out. Where a model helps is on a large, unfamiliar one: scanning for every subscription/cache/closure/pool pattern across a whole codebase, matching each to the right runtime semantics, and pointing at the one specific reference chain that's actually unbounded instead of listing all six categories as equally likely — which is what the memory-leak-detective skill in the Claude Code Debug Pack does. If the growth check comes back as a plateau instead of unbounded growth, you may actually be looking at a one-time slowdown — see the companion guide on perf regression hunting. If the leak turns out to trace back to a single specific bug rather than a systemic pattern, see the one on root-cause tracing. If the growth (or wrong result) only shows up under concurrent load or in a specific thread/task ordering, see the one on concurrency bug finding.