Back to Blog

Node.js Memory Leak Debugging — How We Found and Fixed a Leak in Production

Published: June 24, 2026
Node.js Memory Leak Debugging — How We Found and Fixed a Leak in Production

The server restarted every six hours like clockwork. Not crashing dramatically — just quietly dying, getting picked up by the orchestrator, and coming back. The monitoring chart looked like a sawtooth: memory climbing, plateauing, then dropping to zero when the process recycled.

For two days we called it "fine, the orchestrator catches it." That is the first stage of memory leak grief — denial. The second stage comes when you realize the restarts are getting faster. Six hours became five, then four. The leak was accelerating, and we had no idea what was causing it.

Node.js memory leak debugging in production is a specific skill. Unlike a syntax error that screams at you from the terminal, a memory leak whispers. It reveals itself only under sustained load, often in a production environment where your usual debugging tools feel risky to use. But once you know the process — monitor, reproduce, snapshot, compare, fix — most leaks are findable in under an hour.

Here is exactly how we debugged ours, the three patterns we now check first, and the tools that make nodejs memory leak debugging in production NestJS apps systematic rather than panicked.

Focused view of a computer screen displaying code and debug information representing nodejs memory leak debugging in production NestJS

Symptoms: Is It a Leak or Just a Spike?

Not every memory increase is a leak. A healthy Node.js process sees memory rise and fall with garbage collection cycles. The difference is the slope and the recovery.

A memory spike climbs quickly under load and drops when the load ends. GC runs, memory returns to baseline. Normal.

A memory leak climbs steadily regardless of load pattern. The baseline creeps upward over hours or days. GC runs but the floor keeps rising. Restarts temporarily reset it — until the process reaches the V8 heap limit (default ~1.4GB capped by --max-old-space-size) and exits with FATAL ERROR: JavaScript heap out of memory.

Our chart showed the second pattern. After every restart, baseline memory started around 180MB. After one hour it was 350MB. After four hours it was 900MB. The orchestrator would restart it around 1.1GB, and the cycle repeated.

Monitoring Memory Usage in Production

You cannot fix what you are not measuring. The first step of nodejs memory leak debugging in production is confirming the leak exists and understanding its rate.

TypeScript
1// memory-monitor.service.ts
2import { Injectable, Logger } from '@nestjs/common';
3import { Interval } from '@nestjs/schedule';
4
5@Injectable()
6export class MemoryMonitorService {
7  private readonly logger = new Logger(MemoryMonitorService.name);
8  private baselineHeap = 0;
9
10  @Interval(30_000)
11  logMemory() {
12    const usage = process.memoryUsage();
13    const heapUsedMB = Math.round(usage.heapUsed / 1024 / 1024);
14    const heapTotalMB = Math.round(usage.heapTotal / 1024 / 1024);
15    const rssMB = Math.round(usage.rss / 1024 / 1024);
16
17    this.logger.log(`heapUsed=${heapUsedMB}MB heapTotal=${heapTotalMB}MB rss=${rssMB}MB`);
18
19    if (this.baselineHeap === 0) {
20      this.baselineHeap = heapUsedMB;
21    }
22
23    const growth = heapUsedMB - this.baselineHeap;
24    if (growth > 200) {
25      this.logger.warn(`Memory growth alert: ${growth}MB above baseline`);
26    }
27  }
28}

Log this to your metrics system — Grafana with Prometheus, Datadog, or even just structured logs that your observability stack ingests. The key number is heapUsed under sustained, stable traffic. If it trends upward for more than 30 minutes without dropping, you have a leak.

Reproducing the Leak Locally Under Simulated Load

Reproducing a leak that only appears under sustained traffic is the hardest part. Our development environment with one or two concurrent requests showed flat memory. The leak needed concurrent users making requests over minutes, not seconds.

We used the k6 load test we had already set up for our pre-launch API load testing — reduced to 20 concurrent users hitting the main API endpoints for 10 minutes. That was enough to trigger the growth pattern.

Bash
1node --inspect dist/main.js

Then in another terminal:

Bash
1k6 run --vus 20 --duration 10m memory-leak-test.js

With the process running under --inspect, we could take heap snapshots at the start and end of the load test and compare what grew.

Close-up of hands coding on a laptop representing active nodejs memory leak debugging in production NestJS development

Taking Heap Snapshots with Node.js --inspect

The Node.js inspector protocol gives you the same Chrome DevTools you use for frontend debugging, pointed at your backend process. Start your app with --inspect, open chrome://inspect in Chrome, and your NestJS process appears in the Remote Target list.

The Node.js debugging documentation covers the full setup, but the short version is:

  1. Start your app with node --inspect dist/main.js
  2. Open Chrome to chrome://inspect
  3. Click "Open dedicated DevTools for Node.js"
  4. Go to the Memory tab
  5. Select "Heap snapshot" and click "Take snapshot"

Take one snapshot before the load test starts and another after five minutes of sustained traffic. The Chrome DevTools memory documentation explains how to compare them — the comparison view shows you exactly which object types grew between the two snapshots, sorted by retained size.

In our case, the comparison showed a massive number of EventListener objects and an accumulated Map that should have been scoped per-request but was static across all instances.

The Root Cause: Event Listeners Not Cleaned Up

Our NestJS application had an audit logging service — a @Injectable({ scope: Scope.REQUEST }) provider that listened to events emitted by the payment processing module. The listener was registered in the service's onModuleInit:

TypeScript
1// payment-audit.service.ts — THE PROBLEMATIC VERSION
2@Injectable({ scope: Scope.REQUEST })
3export class PaymentAuditService implements OnModuleInit {
4  private auditBuffer: AuditEntry[] = [];
5  private readonly requestId: string;
6
7  constructor(
8    @Inject(REQUEST) private request: Request,
9    private eventEmitter: EventEmitter2,
10  ) {
11    this.requestId = request.headers['x-request-id'] as string || crypto.randomUUID();
12  }
13
14  onModuleInit() {
15    this.eventEmitter.on('payment.completed', (event: PaymentEvent) => {
16      this.auditBuffer.push({
17        requestId: this.requestId,
18        ...event,
19      });
20    });
21  }
22}

The issue was subtle but deadly. Every new HTTP request created a new PaymentAuditService instance (because of Scope.REQUEST). Each instance registered a listener on the global EventEmitter2 — and never removed it. After 10,000 requests, there were 10,000 event listeners, each holding a reference to its enclosing service instance, which held the entire Request object (headers, body, everything — 50KB+ per instance).

The heap snapshot told the story: thousands of PaymentAuditService instances in memory that should have been garbage-collected after each request, all because the event emitter held a reference to each one.

The fix used OnModuleDestroy to clean up the listener:

TypeScript
1// payment-audit.service.ts — THE FIXED VERSION
2@Injectable({ scope: Scope.REQUEST })
3export class PaymentAuditService implements OnModuleDestroy {
4  private auditBuffer: AuditEntry[] = [];
5  private readonly requestId: string;
6  private readonly handler: (event: PaymentEvent) => void;
7
8  constructor(
9    @Inject(REQUEST) private request: Request,
10    private eventEmitter: EventEmitter2,
11  ) {
12    this.requestId = request.headers['x-request-id'] as string || crypto.randomUUID();
13    this.handler = this.handlePaymentEvent.bind(this);
14    this.eventEmitter.on('payment.completed', this.handler);
15  }
16
17  private handlePaymentEvent(event: PaymentEvent) {
18    this.auditBuffer.push({
19      requestId: this.requestId,
20      ...event,
21    });
22  }
23
24  onModuleDestroy() {
25    this.eventEmitter.off('payment.completed', this.handler);
26  }
27}

The key changes: store the handler reference so off() can find it, and call off() in onModuleDestroy() which NestJS invokes when the request-scoped instance is destroyed.

After this fix, the heap snapshot comparison showed zero accumulated PaymentAuditService instances. Memory stayed flat under load.

Another Common Cause: Growing In-Memory Cache With No Eviction

The event listener leak was our root cause, but while debugging we found a second pattern that would have become a problem later: a service that cached reference data in a static Map with no size limit or TTL.

TypeScript
1// reference-cache.service.ts — PROBLEMATIC
2@Injectable()
3export class ReferenceCacheService {
4  private static cache = new Map<string, any>();
5  private static readonly CACHE_TTL = 60_000; // 1 minute
6
7  async getReference(key: string): Promise<any> {
8    if (ReferenceCacheService.cache.has(key)) {
9      return ReferenceCacheService.cache.get(key);
10    }
11    const data = await this.fetchFromDatabase(key);
12    ReferenceCacheService.cache.set(key, data);
13    setTimeout(() => ReferenceCacheService.cache.delete(key), ReferenceCacheService.CACHE_TTL);
14    return data;
15  }
16}

The setTimeout based eviction works in theory but creates a reference chain: the timeout callback holds a closure over the Map and the key, preventing garbage collection until the timeout fires. Under high throughput with many unique keys, the cache grows until the timeout catches up — which it never does if keys are inserted faster than the TTL expires.

The better approach in NestJS is to use @nestjs/cache-manager with a proper eviction strategy, as covered in our Redis caching strategy post. For in-process caching where Redis is overkill, use Map with an explicit eviction check on read rather than lazy setTimeout cleanup.

Verification: Confirming the Fix

After deploying the fix, we ran the same k6 load test against the updated service — 20 concurrent users, 10 minutes — with --inspect running and heap snapshots taken at the start and end.

Before the fix: heap used grew from 180MB to 920MB over 10 minutes. The heap snapshot comparison showed 8,400 PaymentAuditService instances retained by event listeners.

After the fix: heap used started at 175MB and ended at 210MB after 10 minutes. The 35MB growth was attributable to normal request processing — and it dropped back to 175MB within 30 seconds of the load ending.

We let the fixed version run in staging for 48 hours under real mirrored traffic. The sawtooth chart flattened. No restarts.

Tools for Ongoing Memory Monitoring

Prevention beats debugging. Here are the tools and practices we now use to catch memory leaks before they reach production.

process.memoryUsage() logging — the same monitor service from the start of this post runs in every NestJS deployment, logging heapUsed every 30 seconds. Grafana charts show the trend over time. Any sustained upward slope triggers an alert.

Clinic.js — the clinic tool from NearForm runs a battery of diagnostic checks against your Node.js process. clinic doctor -- node dist/main.js combined with a load test auto-detects memory issues and generates a visual report. It is effectively an automated version of the manual heap snapshot comparison we did.

ESLint rule for unremoved listeners — we added a custom ESLint rule that flags this.eventEmitter.on( calls without a corresponding this.eventEmitter.off( in the same class's OnModuleDestroy lifecycle hook. It is not foolproof (some listeners should persist for the app lifetime), but it catches the pattern that caused our leak.

Code review checklist — any PR that registers an event listener, opens a WebSocket connection, or subscribes to an RxJS observable in a REQUEST-scoped provider must include the corresponding cleanup call. This is a review gate, not optional.

The Three Leak Patterns We Check First

Everything we have learned from this debugging session, and from related performance work like the PostgreSQL query optimization that cut an 8-second dashboard to 340ms, comes down to a short list of checks:

  1. Event listeners registered but never removed — especially in request-scoped providers or controllers that register listeners in their constructor.
  2. In-memory caches and Maps with no eviction policy — every unbounded collection is a memory leak waiting to happen.
  3. Closures capturing large object references — a callback that references this.request or a large data structure keeps that object alive as long as the callback exists.

Most production Node.js memory leaks fit one of these three patterns. If you are staring at a climbing heap chart right now and do not know where to start — start there.

The server that restarted every six hours taught us more about our codebase than any feature ever did. We found the event listener leak, fixed it, and tightened our monitoring and review practices so the next one gets caught in staging rather than at 3am. Monitor from day one, snapshot under load, compare what grew, and fix the pattern that caused it. That is the entire process, and it works.

Frequently Asked Questions

A Node.js memory leak occurs when the V8 garbage collector cannot free memory because objects are still referenced even though they are no longer needed. Memory usage grows over time until the process hits the heap limit (default ~1.4GB in V8) and crashes. Find it by monitoring process.memoryUsage() over time — a steady upward trend in heapUsed under stable traffic is the telltale sign. Confirm with heap snapshots taken under load, compared in Chrome DevTools to identify which objects are accumulating.

Start your NestJS app with the --inspect flag: node --inspect dist/main.js. Open Chrome DevTools (chrome://inspect), find your process in the Remote Target list, and open the dedicated DevTools panel. Navigate to the Memory tab, select 'Heap snapshot' as the profiling type, and click 'Take snapshot.' Compare two snapshots taken minutes apart under production-like load — the objects that grow between snapshots are your leak candidates.

Unremoved event listeners are the most common cause. NestJS uses event emitters extensively — custom EventEmitter services, WebSocket gateway events, TypeORM subscriber events, and RxJS subscriptions in interceptors and guards. If a listener is registered in every request (e.g., in a REQUEST-scoped provider or a controller constructor) but never removed, each listener holds a reference to the enclosing scope and accumulates memory over time. The second most common pattern is a growing in-memory cache or Map that has no eviction policy.

Follow three rules: always pair addListener with removeListener (or use once for single-fire events), use WeakRef/WeakMap for optional references that should not prevent garbage collection, and bound every collection (Map, Set, array) with a maximum size or TTL eviction. Add an ESLint rule to detect unbound event listeners. In code review, flag any registration of a listener in a constructor or REQUEST-scoped provider without a corresponding cleanup in onModuleDestroy.

Start with process.memoryUsage() logged to your metrics system every 30-60 seconds. Grafana with Prometheus can chart heapUsed and heapTotal over time to show growth trends. Clinic.js (npm install -g clinic) provides a doctor command that profiles your app and auto-detects memory or CPU issues. For deeper analysis, Node.js's built-in --inspect flag with Chrome DevTools gives you snapshot comparison and allocation profiling. In production, expose a protected /debug/heapdump endpoint that triggers a heap snapshot via the v8 module on demand.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio