Node API stable at 512 MB RAM. One month later: PM2 restarts every four hours. Added max_memory_restart=800M — restart loop forty per minute at noon peak. Post-mortem: global Map cache keyed by userId without eviction, shipped with "temporary sessions" feature — slow leak, invisible in ten-minute test.
Node does not aggressively return memory to the OS — heapUsed can mislead after garbage collection. A leak is trend growth under representative load, not a post-deploy spike. Nightly restart hides the problem until the interval is no longer enough.
Monitor before restart
Measure what matters:
| Metric | Signal |
|---|---|
process.memoryUsage().heapUsed | Trend after garbage collection |
| RSS | OOM killer watches RSS |
| Event loop lag | Leak + load = event loop latency |
| GC pause frequency | Rises if heap grows |
Common tools:
- Prometheus with node_exporter and app
/metrics - PM2
pm2 monit,--max-memory-restartwith alert if restart count rises - APM (Datadog, etc.) to correlate heap and slow requests
Practical rule: scheduled restart masks — alert RSS > baseline × 1.5 for one hour before adding a nightly cron.
Restart without a graph = paying for ambulance without diagnosis.
Heap diagnosis
Procedure in staging, then production off-peak:
- Reproduce long load (k6 for at least two hours).
- Capture heap snapshot at start and end —
--inspector--heapsnapshot-near-heap-limit(Node 18+). - Compare in Chrome DevTools (Comparison) — objects with rising count.
- Walk retainers (Closure, Array, Buffer).
Common causes on web APIs:
setIntervalnever cleared withclearInterval- Duplicate
socket.onwithoutremoveListener - Global
{ [reqId]: largeObject }never deleted - Logging library keeping context references
Hosting and memory limits
In container or Kubernetes, memory limit kills Node via SIGKILL without graceful shutdown. Size limit above max heap plus margin for native code — or fix the leak.
On VPS, swap masks then kills performance — alert on RSS, not CPU only. Single Node process: leak affects whole service. PM2 cluster mode does not fix leak — it multiplies it across workers.
Compare VPS offers suited to Node via the directory and compare tool. For deployment, cross-read PM2 in production.
PM2: restart vs fix
max_memory_restart: OK temporarily with priority investigation ticket.
Nightly cron restart: tolerable if no known leak (OS memory return quirk) — not a substitute for fix.
Pair max_restarts and exp_backoff_restart_delay to avoid restart loops cutting WebSockets at peak.
The peak: automatic restart is compounding debt
Here is what PM2 hides when everything looks stable.
Team culture: heap snapshot before adding max_memory_restart — or at least a dated root-cause ticket.
Decide and move forward without blind spots
Before adding a restart cron:
- Seven-day RSS graph under normal traffic — identify trend, not isolated spikes.
- Alert on PM2 restart count — not only HTTP uptime.
- Long load test in staging — at least two hours to reveal slow leak.
- Fix retainers identified in snapshot — Map without TTL, listeners, timers.
- Remove cron restart if memory curve stabilises after fix.
See PM2 deployment for supervision best practices, and the directory to pick a VPS with enough RAM for off-peak heap snapshot.
Frequently asked questions
How to detect Node.js memory leak?
Watch RSS or heapUsed over hours or days under stable traffic. If the curve rises without dropping after garbage collection, likely leak. Spike then drop is normal; rising plateau is not.
Is PM2 max_memory_restart enough?
Palliative — recycles before OOM but loses state, hides root cause, may loop. Pair with alert and heap investigation before considering the problem solved.
heapdump in production?
Possible off-peak with --heapsnapshot-near-heap-limit (Node 18+) or manually triggered heapdump module. Analyse in Chrome DevTools — avoid at peak without measuring impact.
Classic leaks on Node web APIs?
Closures keeping request references, event listeners not removed, Map cache without TTL, aggregated buffers, forgotten timers — each leaves RSS slowly climbing.
Watching the memory curve costs less than PM2 looping restarts the day the leak outruns cron.