NestJS course Β· Module 8: Caching and Performance

Memory Management - optimizing fort storage

11 min read
In this lesson5

Storehouse keeper! Consul Caesar.js has noticed that our fort is overloaded: after a week of work the process takes twice as much memory as after startup, and the legion has less and less room to manoeuvre. Eventually the container hits its memory limit and dies in the middle of the day. Time to learn memory management in Node.js.

What is memory management in the legionaries' world?

Imagine the application as a system of storehouses:

  • heap - the main storehouse, where tributes (objects) are kept,
  • stack - the formation where function calls and their local variables stand temporarily,
  • garbage collector - a unit clearing away items nobody reaches for any more,
  • memory leaks - tributes that were forgotten, but someone still holds a key to them.

Good memory management means an efficient, fast fort.

Memory monitoring

process.memoryUsage() returns five numbers: rss is the whole process memory, heapTotal the reserved heap, heapUsed its occupied part, external the memory of C++ objects bound to JavaScript, and arrayBuffers the buffers. The monitoring service samples them every 10 seconds:

1// memory-monitor.service.ts
2import { Injectable } from '@nestjs/common';
3import { Cron } from '@nestjs/schedule';
4import { getHeapStatistics } from 'node:v8';
5
6@Injectable()
7export class MemoryMonitorService {
8  private memoryHistory: Array<{
9    timestamp: Date;
10    usage: NodeJS.MemoryUsage;
11  }> = [];
12
13  private readonly MEMORY_THRESHOLD_MB = 512; // Alert at 512MB
14  private readonly HISTORY_SIZE = 100;
15  // The real heap limit: the default one or the one from --max-old-space-size
16  private readonly HEAP_LIMIT = getHeapStatistics().heap_size_limit;
17
18  @Cron('*/10 * * * * *') // Every 10 seconds
19  checkMemoryUsage(): void {
20    const usage = process.memoryUsage();
21
22    this.memoryHistory.push({ timestamp: new Date(), usage });
23
24    // Keep only the last 100 measurements
25    if (this.memoryHistory.length > this.HISTORY_SIZE) {
26      this.memoryHistory.shift();
27    }
28
29    // Check whether memory exceeds the threshold
30    const heapUsedMB = usage.heapUsed / 1024 / 1024;
31    if (heapUsedMB > this.MEMORY_THRESHOLD_MB) {
32      console.warn(`High memory usage: ${heapUsedMB.toFixed(2)}MB`);
33      this.suggestOptimizations(usage);
34    }
35
36    // Log once a minute
37    if (new Date().getSeconds() === 0) {
38      this.logMemoryStatus(usage);
39    }
40  }

@Cron requires ScheduleModule.forRoot(). The real heap limit comes from getHeapStatistics() in node:v8: heapTotal is only the current reservation, which V8 grows by itself, so the first version, comparing against it, raised alarms for a perfectly healthy process.

Once a minute the service prints the state and computes the trend:

1  private logMemoryStatus(usage: NodeJS.MemoryUsage): void {
2    const formatMB = (bytes: number) => (bytes / 1024 / 1024).toFixed(2);
3
4    console.log('Castrum storehouse status:');
5    console.log(`Heap used: ${formatMB(usage.heapUsed)}MB`);
6    console.log(`Heap total: ${formatMB(usage.heapTotal)}MB`);
7    console.log(`RSS (physical memory): ${formatMB(usage.rss)}MB`);
8    console.log(`External: ${formatMB(usage.external)}MB`);
9
10    // Compute memory growth
11    const growthInfo = this.calculateMemoryGrowth();
12    if (growthInfo.isGrowing) {
13      console.log(`Trend: +${growthInfo.growthRate.toFixed(2)}MB/min`);
14    }
15  }
16
17  private calculateMemoryGrowth(): { isGrowing: boolean; growthRate: number } {
18    if (this.memoryHistory.length < 10) return { isGrowing: false, growthRate: 0 };
19
20    const recent = this.memoryHistory.slice(-10);
21    const oldest = recent[0];
22    const newest = recent[recent.length - 1];
23
24    const timeDiffMin = (newest.timestamp.getTime() - oldest.timestamp.getTime()) / 60000;
25    const memoryDiffMB = (newest.usage.heapUsed - oldest.usage.heapUsed) / 1024 / 1024;
26
27    const growthRate = memoryDiffMB / timeDiffMin;
28
29    return {
30      isGrowing: growthRate > 1, // More than 1MB/min
31      growthRate,
32    };
33  }

The trend compares the oldest and the newest of the last ten measurements. Growth of more than 1 MB per minute that never goes down is the first symptom of a leak.

Suggestions and alerts check the same thresholds:

1  private suggestOptimizations(usage: NodeJS.MemoryUsage): void {
2    console.log('Optimization suggestions:');
3
4    if (usage.heapUsed / this.HEAP_LIMIT > 0.9) {
5      console.log(' - Heap close to the limit: take a heap snapshot and look for a leak');
6    }
7
8    if (usage.external > 50 * 1024 * 1024) {
9      console.log(' - Check buffers and streams - maybe too much data in memory');
10    }
11
12    console.log(" - Check caches for potential memory leaks");
13    console.log(' - Consider a shorter TTL for temporary data');
14  }
15
16  getMemoryReport() {
17    const current = process.memoryUsage();
18    const formatMB = (bytes: number) => (bytes / 1024 / 1024).toFixed(2);
19
20    return {
21      current: {
22        heapUsed: formatMB(current.heapUsed) + 'MB',
23        heapTotal: formatMB(current.heapTotal) + 'MB',
24        heapLimit: formatMB(this.HEAP_LIMIT) + 'MB',
25        rss: formatMB(current.rss) + 'MB',
26        external: formatMB(current.external) + 'MB',
27      },
28      trend: this.calculateMemoryGrowth(),
29      history: this.memoryHistory.slice(-20), // The last 20 measurements
30      alerts: this.generateAlerts(current),
31    };
32  }
33
34  private generateAlerts(usage: NodeJS.MemoryUsage): string[] {
35    const alerts = [];
36    const heapUsedMB = usage.heapUsed / 1024 / 1024;
37
38    if (heapUsedMB > this.MEMORY_THRESHOLD_MB) {
39      alerts.push(`High heap usage: ${heapUsedMB.toFixed(2)}MB`);
40    }
41
42    if (usage.heapUsed / this.HEAP_LIMIT > 0.85) {
43      alerts.push('The heap uses over 85% of the limit - an out of memory error is close');
44    }
45
46    const growthInfo = this.calculateMemoryGrowth();
47    if (growthInfo.isGrowing && growthInfo.growthRate > 5) {
48      alerts.push(`Fast memory growth: ${growthInfo.growthRate.toFixed(2)}MB/min`);
49    }
50
51    return alerts;
52  }

The old version advised "run global.gc()" here. That is bad advice: V8 cleans up by itself, and a manual GC only pauses the application. A full collection has one honest use - diagnostics:

1  // Diagnostics: does memory come back after a full GC? (requires the --expose-gc flag)
2  forceGarbageCollection(): { before: number; after: number; freed: number } {
3    const beforeMB = process.memoryUsage().heapUsed / 1024 / 1024;
4
5    if (global.gc) {
6      global.gc();
7      const afterMB = process.memoryUsage().heapUsed / 1024 / 1024;
8      const freedMB = beforeMB - afterMB;
9
10      console.log(`Garbage collection: freed ${freedMB.toFixed(2)}MB`);
11
12      return {
13        before: beforeMB,
14        after: afterMB,
15        freed: freedMB,
16      };
17    } else {
18      console.warn('Garbage collection unavailable - run with the --expose-gc flag');
19      return { before: beforeMB, after: beforeMB, freed: 0 };
20    }
21  }
22}

If memory does not drop after a forced GC, someone still holds the objects, which means you have a leak. Turn on the --expose-gc flag only for such an investigation.

The event loop - one road through the camp

JavaScript in Node.js runs in a single thread that handles events in a loop. The Node.js documentation lists its phases in this order: timers (setTimeout, setInterval), pending callbacks (deferred I/O callbacks, formerly called I/O callbacks), idle and prepare, poll (new I/O events), check (setImmediate) and close callbacks. When one function computes synchronously for a second, the event loop is blocked and stops handling every other request.

The loop delay is measured by monitorEventLoopDelay() from perf_hooks, which collects samples into a histogram:

1// event-loop.monitor.ts
2import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
3import { Interval } from '@nestjs/schedule';
4import { monitorEventLoopDelay } from 'node:perf_hooks';
5
6@Injectable()
7export class EventLoopMonitor implements OnModuleDestroy {
8  private readonly logger = new Logger(EventLoopMonitor.name);
9  private readonly histogram = monitorEventLoopDelay({ resolution: 20 });
10
11  constructor() {
12    this.histogram.enable();
13  }
14
15  @Interval(10000) // Every 10 seconds
16  check() {
17    const p99 = this.histogram.percentile(99) / 1e6; // nanoseconds to milliseconds
18    if (p99 > 100) {
19      this.logger.warn(`Event loop delayed: p99 = ${p99.toFixed(1)} ms`);
20    }
21    this.histogram.reset();
22  }
23
24  onModuleDestroy() {
25    this.histogram.disable();
26  }
27}

The histogram reports values in nanoseconds, hence the division by 1e6. In a test a 200 ms block gave a p99 of 208 ms.

Techniques for avoiding blocking, from the simplest:

  1. async/await instead of synchronous operations, e.g. fs.promises instead of readFileSync,
  2. moving heavy computations to a Bull queue (today BullMQ) handled by a separate process,
  3. worker threads from node:worker_threads for CPU-heavy tasks,
  4. clustering with PM2, that is several processes on several cores.

Leaks - forgotten tributes

The most common cause of a leak is unreleased references: a listener added on every request and never removed, a closure holding a big object, a map that grows forever or a forgotten timer. The diagnosis goes step by step:

  1. monitor growing memory usage (process.memoryUsage),
  2. take a heap dump and analyse object retention,
  3. identify unreleased references and listeners,
  4. fix the leak and verify memory stability.

A heap dump analysis shows which objects take up memory and who holds them; you will meet the tools for taking dumps in the profiling lesson.

Optimizing caches and collections

A classic leak is a cache without a limit. Ours limits both the number of items and memory:

1// memory-optimized-cache.service.ts
2import { Injectable } from '@nestjs/common';
3import { Cron } from '@nestjs/schedule';
4
5@Injectable()
6export class MemoryOptimizedCacheService {
7  private cache = new Map<string, {
8    data: any;
9    timestamp: number;
10    accessCount: number;
11    lastAccess: number;
12    size: number;
13    timer: NodeJS.Timeout;
14  }>();
15
16  private maxSize = 1000; // Maximum number of items
17  private maxMemoryMB = 100; // Maximum cache memory
18  private currentMemoryBytes = 0;
19
20  set(key: string, value: any, ttl: number = 300000): void {
21    const size = this.estimateSize(value);
22
23    // Remove the old item if it exists - together with its timer
24    this.delete(key);
25
26    // Free space until the new item fits
27    while (this.cache.size > 0 && (this.cache.size >= this.maxSize ||
28      (this.currentMemoryBytes + size) > this.maxMemoryMB * 1024 * 1024)) {
29      this.evictOldest();
30    }
31
32    // Set the TTL; unref() does not keep the process alive
33    const timer = setTimeout(() => this.delete(key), ttl);
34    timer.unref();
35
36    this.cache.set(key, {
37      data: value,
38      timestamp: Date.now(),
39      accessCount: 0,
40      lastAccess: Date.now(),
41      size,
42      timer,
43    });
44
45    this.currentMemoryBytes += size;
46  }
47
48  get(key: string): any {
49    const item = this.cache.get(key);
50    if (!item) return null;
51
52    // Update access statistics
53    item.accessCount++;
54    item.lastAccess = Date.now();
55
56    return item.data;
57  }
58
59  delete(key: string): boolean {
60    const item = this.cache.get(key);
61    if (item) {
62      clearTimeout(item.timer);
63      this.currentMemoryBytes -= item.size;
64      return this.cache.delete(key);
65    }
66    return false;
67  }

This is the corrected version. The first one created a new timer on every set() without cancelling the old one - the old timer fired later and deleted the fresh value. delete() now clears the timer, and unref() means a waiting timer does not block the process from exiting. The while loop frees as much space as needed, not just one item.

The eviction strategy depends on the memory state:

1  // Eviction by different strategies
2  private evictOldest(): void {
3    if (this.cache.size === 0) return;
4
5    const strategy = this.getEvictionStrategy();
6
7    switch (strategy) {
8      case 'LRU': // Least Recently Used
9        this.evictLRU();
10        break;
11      case 'LFU': // Least Frequently Used
12        this.evictLFU();
13        break;
14      case 'SIZE': // Largest items first
15        this.evictLargest();
16        break;
17      default:
18        this.evictOldestByTime();
19    }
20  }
21
22  private getEvictionStrategy(): string {
23    // Pick a strategy based on the memory state
24    const memoryPressure = this.currentMemoryBytes / (this.maxMemoryMB * 1024 * 1024);
25
26    if (memoryPressure > 0.9) {
27      return 'SIZE'; // Under high memory pressure - remove large items
28    } else if (this.cache.size > this.maxSize * 0.8) {
29      return 'LRU'; // Many items - remove the least recently used
30    } else {
31      return 'LFU'; // Normally - remove the least popular
32    }
33  }

Under memory pressure we remove the largest items, with many items the least recently used (LRU), and normally the least frequently used (LFU).

The four strategies differ only in the criterion:

1  private evictLRU(): void {
2    let oldestKey = '';
3    let oldestTime = Infinity;
4
5    this.cache.forEach((item, key) => {
6      if (item.lastAccess < oldestTime) {
7        oldestTime = item.lastAccess;
8        oldestKey = key;
9      }
10    });
11
12    if (oldestKey) {
13      console.log(`LRU eviction: ${oldestKey}`);
14      this.delete(oldestKey);
15    }
16  }
17
18  private evictLFU(): void {
19    let leastUsedKey = '';
20    let leastUsedCount = Infinity;
21
22    this.cache.forEach((item, key) => {
23      if (item.accessCount < leastUsedCount) {
24        leastUsedCount = item.accessCount;
25        leastUsedKey = key;
26      }
27    });
28
29    if (leastUsedKey) {
30      console.log(`LFU eviction: ${leastUsedKey}`);
31      this.delete(leastUsedKey);
32    }
33  }
34
35  private evictLargest(): void {
36    let largestKey = '';
37    let largestSize = 0;
38
39    this.cache.forEach((item, key) => {
40      if (item.size > largestSize) {
41        largestSize = item.size;
42        largestKey = key;
43      }
44    });
45
46    if (largestKey) {
47      console.log(`SIZE eviction: ${largestKey} (${largestSize} bytes)`);
48      this.delete(largestKey);
49    }
50  }
51
52  private evictOldestByTime(): void {
53    let oldestKey = '';
54    let oldestTime = Infinity;
55
56    this.cache.forEach((item, key) => {
57      if (item.timestamp < oldestTime) {
58        oldestTime = item.timestamp;
59        oldestKey = key;
60      }
61    });
62
63    if (oldestKey) {
64      console.log(`TIME eviction: ${oldestKey}`);
65      this.delete(oldestKey);
66    }
67  }

The searches start from Infinity. The first version started from Date.now(), so items added in the same millisecond were never older and nothing disappeared: in a test a cache limited to 3 grew to 10 items.

Finally, size estimation, statistics and cleanup:

1  private estimateSize(obj: any): number {
2    // Rough estimate of the object's size in memory
3    const jsonString = JSON.stringify(obj);
4    return jsonString.length * 2; // UTF-16 = 2 bytes per character
5  }
6
7  getStats() {
8    const memoryUsageMB = this.currentMemoryBytes / 1024 / 1024;
9    const avgItemSize = this.cache.size > 0 ? this.currentMemoryBytes / this.cache.size : 0;
10
11    let totalAccesses = 0;
12    this.cache.forEach(item => {
13      totalAccesses += item.accessCount;
14    });
15
16    return {
17      size: this.cache.size,
18      maxSize: this.maxSize,
19      memoryUsageMB: memoryUsageMB.toFixed(2),
20      maxMemoryMB: this.maxMemoryMB,
21      avgItemSize: Math.round(avgItemSize),
22      totalAccesses,
23      fillRatio: (this.cache.size / this.maxSize * 100).toFixed(1) + '%',
24      memoryRatio: (memoryUsageMB / this.maxMemoryMB * 100).toFixed(1) + '%',
25    };
26  }
27
28  // Cleaning up expired items
29  @Cron('0 */5 * * * *') // Every 5 minutes (the first field is seconds)
30  cleanupExpired(): void {
31    const now = Date.now();
32    const toDelete = [];
33
34    this.cache.forEach((item, key) => {
35      // Remove items not accessed for over an hour
36      if (now - item.lastAccess > 3600000) {
37        toDelete.push(key);
38      }
39    });
40
41    toDelete.forEach(key => {
42      this.delete(key);
43    });
44
45    if (toDelete.length > 0) {
46      console.log(`Cleared ${toDelete.length} stale cache items`);
47    }
48  }
49}

JSON.stringify gives only a rough size, because an object in memory takes up space differently from its text. The pattern 0 */5 * * * * means every 5 minutes; the old */5 * * * * * fired every 5 seconds, because the first field is seconds.

I recommend the ready-made lru-cache library or the simple trick from the editor next to the lesson: Map remembers insertion order, so you remove the oldest entry in constant time. Write your own cache only to understand the mechanism. In the next lesson we will limit traffic before it floods the storehouses.

Remember: in a well-run storehouse every tribute has a best-before date and a place on the shelf, instead of lying forgotten in a corner.

Code for this lesson: src/memory-management.ts
1// Memory Management - Optimizing the Cohort's Stores
2import { Injectable, Logger } from '@nestjs/common';
3
4// 1. Memory monitoring
5@Injectable()
6export class MemoryMonitor {
7  private readonly logger = new Logger('MemoryMonitor');
8
9  getMemoryUsage() {
10    const usage = process.memoryUsage();
11    return {
12      rss: Math.round(usage.rss / 1024 / 1024),          // MB
13      heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
14      heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
15      external: Math.round(usage.external / 1024 / 1024),
16      heapPercent: Math.round(
17        (usage.heapUsed / usage.heapTotal) * 100
18      ),
19    };
20  }
21
22  // Check whether memory is within limits
23  checkHealth(thresholdMB: number = 512): {
24    healthy: boolean;
25    message: string;
26  } {
27    const mem = this.getMemoryUsage();
28    const healthy = mem.heapUsed < thresholdMB;
29
30    return {
31      healthy,
32      message: healthy
33        ? `Memory OK: ${mem.heapUsed}MB / ${thresholdMB}MB`
34        : `WARNING: ${mem.heapUsed}MB exceeds ${thresholdMB}MB!`,
35    };
36  }
37}
38
39// 2. Avoiding memory leaks
40
41// BAD - memory leak (array grows without end):
42// class BadService {
43//   private cache: any[] = [];
44//   addToCache(data: any) {
45//     this.cache.push(data); // NEVER cleared!
46//   }
47// }
48
49// GOOD - cache with a limit:
50class BoundedCache<T> {
51  private items: Map<string, { data: T; addedAt: number }> = new Map();
52  private maxSize: number;
53  private ttlMs: number;
54
55  constructor(maxSize = 1000, ttlMs = 300000) {
56    this.maxSize = maxSize;
57    this.ttlMs = ttlMs;
58  }
59
60  set(key: string, data: T) {
61    // Remove oldest if too many
62    if (this.items.size >= this.maxSize) {
63      const oldest = this.items.keys().next().value;
64      this.items.delete(oldest);
65    }
66    this.items.set(key, { data, addedAt: Date.now() });
67  }
68
69  get(key: string): T | null {
70    const item = this.items.get(key);
71    if (!item) return null;
72
73    // Check TTL
74    if (Date.now() - item.addedAt > this.ttlMs) {
75      this.items.delete(key);
76      return null;
77    }
78    return item.data;
79  }
80
81  get size() { return this.items.size; }
82}
83
84// 3. Garbage Collector
85// Node.js manages memory automatically (GC)
86// But you can force it: global.gc() (with the --expose-gc flag)
87
88// 4. Streams instead of loading whole files into memory
89// BAD:  const data = fs.readFileSync('huge-file.csv');
90// GOOD: const stream = fs.createReadStream('huge-file.csv');
91
92// 5. WeakMap for temporary data
93// const cache = new WeakMap();
94// Objects in a WeakMap can be garbage collected!
95

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Event loop blocking in Node.js causes:

  2. 2. Worker threads in Node.js are best used for:

These are 2 of 4 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Write a service that monitors event loop delays and logs warnings

  • Vertical ordering

    Arrange the Node.js event loop phases:

  • Click in order

    Arrange techniques for avoiding event loop blocking from simplest:

  • Code editor

    Write a service that collects memory metrics (heapUsed, rss, external)

  • Vertical ordering

    Arrange the memory leak diagnostic steps:

  • Vertical ordering

    Arrange caching layers from closest to the user:

Useful articles