Memory: the V8 heap, garbage collection, and leaks
A server leaks what it stores per visit and forgets per ending; snapshots over time show it.
Predict first
Before you read: predict
ExamHall's proctoring gateway keeps Map<attemptId, ProctorSession> and loads each exam's paper into a second Map the first time a candidate needs it. It never deletes a session. Three equal batches of 300 candidates each sit one of the same three exams: connect, get "ok", disconnect. An engineer takes heap snapshot S0 before the first batch and S1, S2, S3 after each batch, then opens S3 in Comparison view against S2.
What does it show?
The 30-second version
A server lives for days, so anything it stores per connection or per request must be removed when that connection or request ends, however it ends. 'end' and 'finish' fire only on a clean ending; 'close' fires on every one. To find what stays, compare two later heap snapshots, then follow one kept object's retainers to the holder.
A server leaks what it stores per visit and forgets per ending; snapshots over time show it.
How it works
Step 1 of 12
Map caches are js-memory-leaks-gc's steps 1–6.Analogy: A lakeside canoe and kayak hire
Each boat goes out with a line on the hire sheet; the boats on the rack are counted every evening at closing.
| At the canoe hire | In Node |
|---|---|
| a line on the hire sheet for each paddler | a record stored per connection or per request (step 3) |
| the line is crossed out when the boat comes back to the jetty | cleanup tied to one way of ending (steps 4–5) |
| a paddler who lands on the far shore and is driven home | an ending the cleanup does not listen for |
| buoyancy aids in each size, brought out on the first morning and left on their pegs | a cache filled once: its working set |
| tonight's count compared with last night's, not with opening day | two later snapshots compared (steps 8–9) |
| following one missing canoe's hull number to the open line that still names it | the retainer path (step 10) |
| no boats left on the rack, so the hire stops for the day | the heap limit, then the abort (step 11) |
In real terms: before you look for a leak, list everything your server stores per connection and per request, and the event that removes it.
Breaks down when: an evening count is cheap; a heap snapshot pauses the process and needs memory of its own, so take it on one instance, not the whole fleet.
Contrasting case: a CGI script or a PHP-FPM worker that exits after each request frees everything it allocated at exit; a Node server never exits between requests, so per-request state stays until code removes it.
Examples, run for real
4.1 ExamHall: the gateway that restarted mid-exam
EXH-3710: ExamHall's proctoring gateway kept Map<attemptId, ProctorSession> and never deleted on disconnect. After three sittings the pod restarted in the middle of an exam. MR !4010 deletes each session on the socket's 'close'.
4.1 proctoring gateway, 3 sittings x 300 candidates (every 20th laptop drops off the Wi-Fi)
as shipped (never deletes) ProctorSession per sitting +300, +300, +300 · sessions.size 900 · retainer path (named edges, upward): table <- sessions <- context <- handler
MR !4010 (delete on 'close') ProctorSession per sitting +0, +0, +0 · sessions.size 0 · retainer path: no ProctorSession left
Each sitting left all 300 sessions behind. One kept session's retainers lead to the gateway's sessions Map, the first holder that outlives a sitting (steps 8, 10).
4.2 Chat: the history that is never trimmed
V8 reports a lower limit, and the child aborts with V8's fatal line (step 11). The MiB values are this machine's and grade nothing.
4.2 chat: a message history that is never trimmed, against the heap limit (child processes; MiB values are this machine's, not graded)
heap_size_limit with no flag: 4288 MiB · with --max-old-space-size=64: 256 MiB
a chat server that keeps every message, --max-old-space-size=64: exit signal SIGABRT · status null · "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A chat API keeps a module-level Map of message sends in flight, for its ops dashboard, and deletes each entry on the response's 'finish'. 100 message sends arrive; the push service is slow, and before it answers, 10 phones lose signal and their requests are gone. Then the push service answers the rest.
After every response has closed, how many PendingSend objects does a heap snapshot still count?
Question 2 of 7
A chat API keeps per-connection data: on 'connection' it puts an empty array for the socket in a module-level Map, and deletes it on the socket's 'close'. Each request pushes one RequestLog into its connection's array. 10 phone apps each send 30 requests through a keep-alive agent and stay connected.
How many RequestLog objects does a heap snapshot count?
Question 3 of 7
A chat gateway stores each ChatSession in a sessions Map and adds a deliver callback, which closes over the session, to a lobby Set. On the socket's 'close' it deletes both. Each session also arms setTimeout(kickIdle, 10 * 60_000), cleared on the socket's 'end'. 300 phones connect, say hello and drop off the network (the connection is reset). Every socket has closed, yet a snapshot still counts 300 ChatSessions.
Which holder will one session's retainer path pass through?
Question 4 of 7
P1's chat API again: 100 message sends, 10 phones gone before the push service answers. The dashboard must show every send that is still waiting, and nothing may stay once a request has ended, however it ended.
Which one change does both?
Question 5 of 7
An interviewer says, "Memory on our Node service grows about 50 MB an hour until the pod restarts," and asks for your plan.
How would you find the cause, in at most 120 words?
Question 6 of 7
A streaming API memoises its manifest builder in a module-level Map keyed by req.url. Each player adds a fresh signed token to every request (/title-3?token=…). Three equal batches of 400 requests spread over 8 titles; snapshots S0 before and S1–S3 after each batch.
What does S3 against S2 show?
Question 7 of 7
A ticketing queue gateway gives each fan a HoldToken and keeps it in a module-level Map<fanId, HoldToken>. A token is valid until the end of the wave it was issued in. To evict expired tokens, the gateway runs a sweep in every socket's 'close' handler that deletes the tokens of every wave that has ended. Three waves of 200 different fans: each fan connects, gets a token and closes the app normally.
After the third wave ends, with no fan connected, how many HoldToken objects does a heap snapshot count?
Go deeper
Pro tips
- Write the ending beside the store. Next to every
map.setin a server, write the handler that deletes it, in the same function, on an event that fires however the visit ends. When: any per-connection or per-request state. - Test leaks by counting, not by watching memory. Run fixed batches, snapshot after each with forced collections, and assert that a constructor's count is flat between the last two. When: a regression test for a leak incident (steps 8–9). Verify (RAN,
lib/heap.mjs): every graded script here counts by constructor this way.
Source
- Node.js Learn, Using Heap Snapshot: https://nodejs.org/en/learn/diagnostics/memory/using-heap-snapshot · fetched 2026-10-13. "You can also compare multiple snapshots to see differences over time"; "Select the newer snapshot and switch mode in the dropdown at the top from Summary to Comparison"; "Look for large positive deltas and explore the references that caused them in the bottom panel" (steps 8–10). Valid as of 2026-10-13.
- Node.js Learn, Memory (diagnostics index): https://nodejs.org/en/learn/diagnostics/memory · fetched 2026-10-13. "Node.js (JavaScript) is a garbage collected language, so having memory leaks is possible through retainers"; "Node.js applications are usually multi-tenant, business critical, and long-running" (steps 2, 10). Valid as of 2026-10-13.
- V8 blog, Trash talk: the Orinoco garbage collector: https://v8.dev/blog/trash-talk · fetched 2026-10-13. "Garbage collectors do this by using reachability as a proxy for 'liveness'. This means that any object currently reachable within the runtime must be kept, and any unreachable objects may be collected" (step 1, linked). Valid as of 2026-10-13.
- Node.js v24 API, HTTP (source
doc/api/http.md, branch v24.x): https://nodejs.org/docs/latest-v24.x/api/http.html · fetched 2026-10-13.http.ServerResponse'close': "Indicates that the response is completed, or its underlying connection was terminated prematurely (before the response completion)";'finish': "Emitted when the response has been sent … It does not imply that the client has received anything yet";http.IncomingMessage'close', changed in v16.0.0: "The close event is now emitted when the request has been completed and not when the underlying socket is closed" (steps 5–6). Valid as of 2026-10-13. - Node.js v24 API, net,
socketEvent'end'(sourcedoc/api/net.md, branch v24.x): https://nodejs.org/docs/latest-v24.x/api/net.html#event-end · fetched 2026-10-13. "Emitted when the other end of the socket signals the end of transmission, thus ending the readable side of the socket" (step 4). Valid as of 2026-10-13. - Node.js v24 API, Command-line options,
--max-old-space-size=SIZE(sourcedoc/api/cli.md): https://nodejs.org/docs/latest-v24.x/api/cli.html · fetched 2026-10-13. "Sets the max memory size of V8's old memory section. As memory consumption approaches the limit, V8 will spend more time on garbage collection in an effort to free unused memory" (step 11). Valid as of 2026-10-13.
Related lessons
Checked against the tools and docs as of 2026-10-13.