Tune Node Heap and GC Settings from Evidence
Chart heap usage and GC pause times across load, identify premature promotion and oversized young generation, then set flags deliberately.
Task
Collect heap and GC telemetry under load, diagnose the allocation pattern, and apply the minimal flag set that improves pause profile.
When to use this
The service hits 1.5GB RSS and stalls for hundreds of milliseconds hourly. Someone suggests raising the heap limit; nobody has looked at the GC log yet.
Prerequisites
- Node.js 16 or newer with --trace-gc and v8.getHeapStatistics
- A load generator for steady reproducible traffic
- A sandbox service instance safe to restart with flags
Steps
1. Record GC events and heap stats under a fixed load window.
node --trace-gc --trace-gc-verbose dist/server.js 2>gc.log & LOAD=200rps; sleep 300; kill %1
grep -c "Scavenge" gc.log; grep -c "Mark-Compact" gc.log; grep "Mark-sweep\|Mark-Compact" gc.log | tail -5
Expected output: Event counts print, for example 4,100 scavenges versus 22 major GCs, and the last major collections show their pause durations.
2. Chart heap growth between major collections.
grep -oE "[0-9.]+ ms.*] [0-9]+ -> [0-9]+ \([0-9]+\) Mb" gc.log | awk '{print $1, $NF}' | head -20 || grep -oE "heap [0-9]+ -> [0-9]+ " gc.log | head -20Expected output: Heap-before values print per major GC; a sawtooth rising from 300 to 900 MB before each collection flags premature promotion of short-lived objects.
3. Quantify GC pause impact on request latency.
jq -r 'select(.route == "/api/search") | [.at, .durationMs] | @tsv' requests.log | awk '{if ($2 > 200) print $1, "latency", $2}' | head -8Expected output: Slow-request timestamps line up with major GC timestamps from gc.log, correlating pauses with user-visible stalls.
4. Apply the minimal flag set matching the diagnosis.
# diagnosis: many scavenges + fast old-gen growth -> objects survive
# two scavenges by being allocated before a request completes
# fix A: bigger young generation, fewer premature promotions
node --max-semi-space-size=64 dist/server.js
# fix B: cap old space so majors happen often but cheaply
node --max-old-space-size=1024 dist/server.js
# fix C (diagnosis was fragmentation): compact on every major
node --expose-gc --max-old-space-size=1024 \
-e "setInterval(() => { if (global.gc) global.gc(); }, 30000).unref?.(); import('./dist/server.js')" 2>/dev/null || node --max-old-space-size=1024 --gc-interval=1024 dist/server.js
Expected output: Under the same 200 rps load, major GC pauses drop from hundreds of milliseconds to tens, and the RSS ceiling stops creeping.
5. Re-run the identical measurement to verify and keep the numbers.
node --max-semi-space-size=64 --trace-gc dist/server.js 2>gc2.log & sleep 300; kill %1; echo "scavenges: $(grep -c Scavenge gc2.log) majors: $(grep -cE 'Mark-(Compact|sweep)' gc2.log)"; grep -oE "\] [0-9]+ -> [0-9]+ \([0-9]+\) Mb" gc2.log | tail -3Expected output: The after-numbers print alongside the before-numbers, documenting the flag decision with the evidence that justified it.
Troubleshooting
Problem
RSS keeps growing despite flat heap
Solution
Memory lives outside V8 heap: Buffers, workers, or native addons; track external memory via process.memoryUsage().external before touching heap flags.
Problem
Bigger semi-space makes latency worse
Solution
The workload allocates long-lived objects per request; larger young generation delays promotion and grows the scavenge cost; revert and fix allocation sites or cap old space instead.
Problem
Major GC pauses persist after tuning
Solution
Large long-lived graphs dominate; reduce retained data (caches with TTL, LRU bounds), because flags cannot shrink the live set your code insists on holding.
Limitations
- GC flags trade pause frequency against pause length; there is no setting that improves both bounds simultaneously.
- Trace flags add overhead; measure with them off after choosing settings, keeping traces for diagnosis only.
Flags chosen without a GC log are superstition
Raising the heap limit on a service with premature promotion makes majors rarer but larger, which is exactly wrong. The GC trace tells you which story you are in: too many scavenges, too-fast old-gen growth, or fragmentation. Each story has a different minimal fix.
gc.log before (200 rps, 5 min):
Scavenge 4,113x avg pause 1.8 ms (healthy)
Mark-Compact 22x avg pause 310 ms (users feel these)
heap before majors: 310 -> 480 -> 690 -> 905 Mb (premature promotion)
# fix: give the young generation room to let request-scoped
# objects die young
node --max-semi-space-size=64 dist/server.js
gc.log after:
Scavenge 980x avg pause 2.9 ms
Mark-Compact 6x avg pause 41 ms (7.5x shorter)
heap before majors: 320 -> 340 -> 345 Mb (stable)
Read the sawtooth before the flags
The heap size before each major GC is the diagnostic: rising steadily means short-lived objects are being promoted to old space, usually by allocations outliving a single request. A flat line means your live set is genuinely large and flags will not save you; the code holds too much.
One flag at a time, same load, both logs
GC behavior responds to workload shape, so the only fair comparison runs identical traffic before and after each flag. Keep both logs and their summary numbers with the deployment config; six months later, the next engineer needs to know why 64MB semi-spaces were chosen, or they will cargo-cult it away.