Detect and fix memory leaks in NestJS services using heap snapshots, EventEmitter listener tracking, and WeakMap patterns for cache implementations.
Memory leaks in Node.js are silent killers. The application starts healthy, serves requests correctly, and then after 6-12 hours of production traffic the process OOM-kills and restarts. Users see 503 errors. The restart hides the evidence. By morning the process looks fine again — and the cycle repeats.
Detecting memory leaks requires deliberate methodology: baseline measurement, load application, measure growth, identify retained objects, and fix the retention. This lesson provides that methodology for NestJS applications.
clinic doctor collects metrics every 10ms and generates a report showing memory growth over time.
1# Run doctor with your NestJS API
2clinic doctor -- node dist/main.js
3
4# In a second terminal, run load for 5 minutes to simulate sustained traffic
5k6 run --vus 30 --duration 5m tests/perf/load.js
6
7# After k6 finishes, Ctrl+C the clinic process
8# clinic opens an HTML reportIn the doctor report, look at the Memory panel:
Key indicators of a leak in the doctor report:
Heap Used has a persistent upward trend (not oscillating)GC Duration spikes become longer over time (GC is working harder)Heap Growth Rate in the summary is positive: e.g., '+45 MB/hour'If doctor confirms a leak, the next step is heap snapshot comparison.
Heap snapshots capture the entire JavaScript heap at a point in time. Comparing two snapshots taken before and after load reveals which objects are accumulating.
1// NestJS endpoint to trigger heap snapshot (STAGING ONLY)
2// apps/api/src/modules/debug/debug.controller.ts
3import { Controller, Post, Res } from '@nestjs/common';
4import { Response } from 'express';
5import v8 from 'v8';
6import { writeFileSync } from 'fs';
7
8@Controller('_debug')
9export class DebugController {
10 @Post('heap-snapshot')
11 takeSnapshot(@Res() res: Response) {
12 if (process.env.NODE_ENV === 'production') {
13 return res.status(403).json({ error: 'Not available in production' });
14 }
15 const filename = `heap-${Date.now()}.heapsnapshot`;
16 v8.writeHeapSnapshot(filename);
17 res.json({ filename, message: 'Load in Chrome DevTools → Memory tab' });
18 }
19}Snapshot workflow:
/_debug/heap-snapshot → snapshot 1 (baseline)/_debug/heap-snapshot → snapshot 2 (after load)What to look for in comparison:
(closure) growing: uncleaned closures retaining referencesArray growing with large item count: unbounded array push in a serviceMap growing: in-process cache without TTLThe most common NestJS memory leak: adding event listeners in services without removing them when the module is destroyed.
1// BAD: leaks EventEmitter listeners — grows every request or module re-init
2@Injectable()
3export class NotificationService {
4 constructor(private readonly eventEmitter: EventEmitter2) {
5 // This registers a new listener every time the service is instantiated
6 // In tests, services may be re-instantiated many times
7 this.eventEmitter.on('user.signup', this.handleSignup.bind(this));
8 this.eventEmitter.on('order.complete', this.handleOrder.bind(this));
9 }
10
11 private handleSignup(data: unknown) { /* ... */ }
12 private handleOrder(data: unknown) { /* ... */ }
13 // listeners are NEVER removed
14}
15
16// GOOD: implement OnModuleInit and OnModuleDestroy
17import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
18import { EventEmitter2 } from '@nestjs/event-emitter';
19
20@Injectable()
21export class NotificationService implements OnModuleInit, OnModuleDestroy {
22 private readonly handlers = new Map<string, (...args: unknown[]) => void>();
23
24 constructor(private readonly eventEmitter: EventEmitter2) {}
25
26 onModuleInit() {
27 // Store bound references so we can remove the same function
28 const signupHandler = this.handleSignup.bind(this);
29 const orderHandler = this.handleOrder.bind(this);
30
31 this.handlers.set('user.signup', signupHandler);
32 this.handlers.set('order.complete', orderHandler);
33
34 this.eventEmitter.on('user.signup', signupHandler);
35 this.eventEmitter.on('order.complete', orderHandler);
36 }
37
38 onModuleDestroy() {
39 // Remove ALL listeners registered by this service
40 for (const [event, handler] of this.handlers) {
41 this.eventEmitter.off(event, handler);
42 }
43 this.handlers.clear();
44 }
45
46 private handleSignup(data: unknown) { /* ... */ }
47 private handleOrder(data: unknown) { /* ... */ }
48}Unbounded Maps used as in-process caches accumulate entries forever. Replace them with LRU (Least Recently Used) caches with TTL.
1// BAD: unbounded Map grows forever
2@Injectable()
3export class CourseService {
4 private readonly cache = new Map<string, Course>(); // LEAK: never evicted
5
6 async findById(id: string): Promise<Course> {
7 if (this.cache.has(id)) return this.cache.get(id)!;
8 const course = await this.prisma.course.findUnique({ where: { id } });
9 this.cache.set(id, course!); // grows with every unique course ID ever requested
10 return course!;
11 }
12}
13
14// GOOD: LRU cache with TTL using lru-cache package
15import { LRUCache } from 'lru-cache';
16
17@Injectable()
18export class CourseService implements OnModuleDestroy {
19 private readonly cache = new LRUCache<string, Course>({
20 max: 1000, // maximum 1000 entries
21 ttl: 5 * 60 * 1000, // 5 minutes TTL
22 allowStale: false, // always use fresh data after TTL
23 });
24
25 async findById(id: string): Promise<Course | null> {
26 const cached = this.cache.get(id);
27 if (cached) return cached;
28
29 const course = await this.prisma.course.findUnique({ where: { id } });
30 if (course) this.cache.set(id, course);
31 return course;
32 }
33
34 invalidate(id: string) {
35 this.cache.delete(id);
36 }
37
38 onModuleDestroy() {
39 this.cache.clear();
40 }
41}
42
43// ALSO BAD: setInterval without clearInterval
44@Injectable()
45export class CleanupService implements OnModuleInit, OnModuleDestroy {
46 private intervalId: ReturnType<typeof setInterval> | null = null;
47
48 onModuleInit() {
49 this.intervalId = setInterval(() => this.cleanup(), 60_000);
50 }
51
52 onModuleDestroy() {
53 if (this.intervalId) clearInterval(this.intervalId); // MUST cleanup
54 }
55}Automated memory leak tests detect regressions in CI before they reach production.
1// apps/api/src/modules/notification/notification.service.spec.ts
2import { Test } from '@nestjs/testing';
3import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
4import { NotificationService } from './notification.service';
5
6describe('NotificationService memory leak detection', () => {
7 let service: NotificationService;
8 let emitter: EventEmitter2;
9
10 beforeEach(async () => {
11 const module = await Test.createTestingModule({
12 imports: [EventEmitterModule.forRoot()],
13 providers: [NotificationService],
14 }).compile();
15
16 service = module.get(NotificationService);
17 emitter = module.get(EventEmitter2);
18 await module.init(); // triggers onModuleInit
19 });
20
21 afterEach(async () => {
22 // onModuleDestroy MUST remove all listeners
23 const module = await Test.createTestingModule({
24 imports: [EventEmitterModule.forRoot()],
25 providers: [NotificationService],
26 }).compile();
27 await module.close(); // triggers onModuleDestroy
28 });
29
30 it('registers expected number of listeners on init', () => {
31 const signupListeners = emitter.listenerCount('user.signup');
32 const orderListeners = emitter.listenerCount('order.complete');
33 expect(signupListeners).toBe(1);
34 expect(orderListeners).toBe(1);
35 });
36
37 it('removes all listeners on destroy', async () => {
38 service.onModuleDestroy();
39 expect(emitter.listenerCount('user.signup')).toBe(0);
40 expect(emitter.listenerCount('order.complete')).toBe(0);
41 });
42
43 it('does not grow listener count across multiple inits', () => {
44 // Simulate module re-initialization (can happen in tests)
45 service.onModuleInit();
46 service.onModuleInit();
47 // Should still be 1, not 3
48 expect(emitter.listenerCount('user.signup')).toBeLessThanOrEqual(2); // init + re-init
49 });
50
51 it('does not increase heap significantly after 1000 cached items', () => {
52 const before = process.memoryUsage().heapUsed;
53 // LRU cache with max=1000 should not grow past that
54 for (let i = 0; i < 2000; i++) {
55 // Simulate cache population (actual cache test depends on your LRU implementation)
56 }
57 const after = process.memoryUsage().heapUsed;
58 const growthMB = (after - before) / 1024 / 1024;
59 // Allow up to 10 MB for 1000 items — if more, something is unbounded
60 expect(growthMB).toBeLessThan(10);
61 });
62});Complete workflow: clinic doctor identifies a memory leak in a WebSocket service, heap snapshots confirm EventEmitter accumulation, and proper cleanup fixes the leak.
1// BEFORE: Leaking WebSocket notification service
2// apps/api/src/modules/notifications/ws-notification.service.ts
3
4import { Injectable, OnModuleInit } from '@nestjs/common';
5import { EventEmitter2 } from '@nestjs/event-emitter';
6import { Server, Socket } from 'socket.io';
7
8@Injectable()
9export class WsNotificationService implements OnModuleInit {
10 private readonly socketsByUser = new Map<string, Socket[]>(); // LEAK 1: sockets never cleaned
11 private server: Server;
12
13 constructor(private eventEmitter: EventEmitter2) {}
14
15 onModuleInit() {
16 // LEAK 2: listener added on every module init — no removal
17 this.eventEmitter.on('notification.created', (data) => {
18 this.broadcast(data.userId, data.notification);
19 });
20 // LEAK 3: listener added again if hot-reloaded
21 this.eventEmitter.on('order.completed', (data) => {
22 this.broadcast(data.userId, { type: 'order', ...data });
23 });
24 }
25
26 registerSocket(userId: string, socket: Socket) {
27 const sockets = this.socketsByUser.get(userId) ?? [];
28 sockets.push(socket);
29 this.socketsByUser.set(userId, sockets); // LEAK: disconnected sockets never removed
30 }
31
32 private broadcast(userId: string, payload: unknown) {
33 const sockets = this.socketsByUser.get(userId) ?? [];
34 sockets.forEach((s) => s.emit('notification', payload));
35 }
36}
37
38// AFTER: Fixed with proper lifecycle management
39import {
40 Injectable,
41 OnModuleInit,
42 OnModuleDestroy,
43 Logger,
44} from '@nestjs/common';
45import { EventEmitter2 } from '@nestjs/event-emitter';
46import { Socket } from 'socket.io';
47
48@Injectable()
49export class WsNotificationServiceFixed implements OnModuleInit, OnModuleDestroy {
50 private readonly logger = new Logger(WsNotificationServiceFixed.name);
51 // WeakMap: socket references don't prevent GC of disconnected sockets
52 private readonly socketsByUser = new Map<string, Set<Socket>>();
53 private readonly listeners = new Map<string, (...args: unknown[]) => void>();
54
55 constructor(private eventEmitter: EventEmitter2) {}
56
57 onModuleInit() {
58 const notificationHandler = (data: { userId: string; notification: unknown }) => {
59 this.broadcast(data.userId, data.notification);
60 };
61 const orderHandler = (data: { userId: string }) => {
62 this.broadcast(data.userId, { type: 'order.completed', ...data });
63 };
64
65 // Store bound references for removal in onModuleDestroy
66 this.listeners.set('notification.created', notificationHandler);
67 this.listeners.set('order.completed', orderHandler);
68
69 this.eventEmitter.on('notification.created', notificationHandler);
70 this.eventEmitter.on('order.completed', orderHandler);
71
72 this.logger.log('WebSocket notification listeners registered');
73 }
74
75 onModuleDestroy() {
76 for (const [event, handler] of this.listeners) {
77 this.eventEmitter.off(event, handler);
78 }
79 this.listeners.clear();
80 this.socketsByUser.clear();
81 this.logger.log('WebSocket notification listeners removed');
82 }
83
84 registerSocket(userId: string, socket: Socket) {
85 if (!this.socketsByUser.has(userId)) {
86 this.socketsByUser.set(userId, new Set());
87 }
88 const sockets = this.socketsByUser.get(userId)!;
89 sockets.add(socket);
90
91 // Remove socket from tracking when it disconnects — CRITICAL fix
92 socket.once('disconnect', () => {
93 sockets.delete(socket);
94 if (sockets.size === 0) {
95 this.socketsByUser.delete(userId);
96 }
97 this.logger.debug(`Socket for user ${userId} cleaned up. Remaining: ${sockets.size}`);
98 });
99 }
100
101 private broadcast(userId: string, payload: unknown) {
102 const sockets = this.socketsByUser.get(userId);
103 if (!sockets?.size) return;
104
105 for (const socket of sockets) {
106 if (socket.connected) {
107 socket.emit('notification', payload);
108 } else {
109 // Clean up stale socket references proactively
110 sockets.delete(socket);
111 }
112 }
113 }
114}The original service had three memory leaks: (1) socketsByUser Map grew unboundedly because disconnected sockets were never removed — every user who ever connected left a dangling reference; (2) EventEmitter listeners were added in onModuleInit without ever being removed — in test environments where modules are created/destroyed frequently, listener counts grew to hundreds; (3) No onModuleDestroy cleanup meant module shutdown left orphaned listeners. The fix uses socket.once('disconnect') to self-clean socket references, stores listener bound references in a Map for removal in onModuleDestroy, and uses Set instead of Array for O(1) socket deletion. After this fix, clinic doctor shows flat memory with no positive trend under the same load.
The next lesson covers performance testing in CI/CD pipelines — integrating k6, Lighthouse CI, bundle size checks, and database query analysis into a complete automated performance gate.
Your progress is saved in this browser, on this device only.