Why Your NestJS API Slows Down Under Load (And It's Probably Not the Database)
A field guide to diagnosing V8 memory leaks and event loop lag in high-throughput NestJS APIs — measuring lag with perf_hooks, killing RxJS subscription leaks, and offloading CPU work to worker threads.
Why Your NestJS API Slows Down Under Load (And It's Probably Not the Database)
Your NestJS service passes load testing at 500 requests per second. Three weeks later it's crash-looping in production at the same traffic level, and the first instinct on every team is the same: check the database. Slow queries, missing indexes, connection pool exhaustion. Sometimes that's right. Often it isn't — the actual culprit is sitting in the Node.js process itself, and it's either a heap that never stops growing or an event loop that's stalling on synchronous work.
Both problems are invisible until you specifically look for them, and both have the same root cause: Node's single-threaded event loop model, which is fantastic for I/O-bound APIs and unforgiving the moment you break its assumptions.
Measure event loop lag before you guess at it
Don't start by staring at CPU graphs. Start by measuring the thing that actually determines whether requests queue up: event loop delay. Node's perf_hooks module ships monitorEventLoopDelay, and it needs no external package.
// src/common/metrics/event-loop.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { monitorEventLoopDelay, IntervalHistogram } from 'perf_hooks';
@Injectable()
export class EventLoopMonitorService implements OnModuleInit {
private readonly logger = new Logger(EventLoopMonitorService.name);
private histogram: IntervalHistogram;
onModuleInit() {
// Monitor event loop delay with a 10ms sampling interval
this.histogram = monitorEventLoopDelay({ resolution: 10 });
this.histogram.enable();
// Log 99th percentile lag every 30 seconds
setInterval(() => {
const p99LagMs = this.histogram.percentile(99) / 1e6; // ns to ms
if (p99LagMs > 50) {
this.logger.warn(`High Event Loop Lag detected! p99: ${p99LagMs.toFixed(2)}ms`);
}
this.histogram.reset();
}, 30_000);
}
}
Wire this up before you touch anything else. A p99 north of 50ms is your signal that something synchronous is blocking the main thread — and once you have that number trending in your logs or dashboards, you stop guessing and start correlating spikes with deploys, cron jobs, or specific endpoints.
The RxJS subscription leak nobody notices until OOM
NestJS leans on RxJS in interceptors, gateways, and event-driven services, and that's exactly where the leaks tend to start. Subscribe to an Observable without ever unsubscribing, and the closure — along with everything it references, including request-scoped data — stays pinned in memory for the life of the process.
// BAD: leaks memory because the subscription never unsubscribes
@Injectable()
export class NotificationService {
listenToOrderUpdates(orderStream$: Observable<OrderEvent>) {
orderStream$.subscribe(event => this.sendPush(event));
}
}
This works fine in dev. It works fine in staging. It fails three weeks into production once enough of these subscriptions have stacked up that the V8 heap can't reclaim them fast enough, and your container starts restart-looping on OOM. The fix is to give every long-lived subscription an explicit lifecycle boundary tied to the module's own teardown:
// GOOD: enforce lifecycle boundaries
@Injectable()
export class NotificationService implements OnModuleDestroy {
private readonly destroy$ = new Subject<void>();
listenToOrderUpdates(orderStream$: Observable<OrderEvent>) {
orderStream$
.pipe(takeUntil(this.destroy$))
.subscribe(event => this.sendPush(event));
}
onModuleDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
If you're auditing an existing codebase for this, grep for .subscribe( and check every hit against a matching takeUntil, firstValueFrom, or explicit .unsubscribe(). Any bare subscribe in a service that isn't request-scoped is worth a second look.
The other common leak source is simpler and easier to miss: a plain Map or object used as an in-memory cache, with no eviction policy. It looks harmless in code review because nothing about cache.set(key, value) screams "memory leak" — it just grows, quietly, until it doesn't. Reach for lru-cache with a hard max-items constraint, or push the cache out to Redis entirely so it's not competing with your application heap.
Stop doing CPU-heavy work on the main thread
Node's event loop is single-threaded by design. That's the whole reason it handles thousands of concurrent I/O-bound requests so well — but it also means any synchronous CPU-bound operation blocks every other request on the process until it finishes. Large JSON parsing, payload compression, cryptographic hashing: none of these belong on the main thread in a high-throughput service.
import { Worker } from 'worker_threads';
import path from 'path';
export function offloadHeavyCalculation(dataPayload: unknown): Promise<string> {
return new Promise((resolve, reject) => {
const worker = new Worker(path.resolve(__dirname, './heavy-worker.js'), {
workerData: dataPayload,
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}
Worker threads aren't free — spinning one up and passing messages back and forth has real overhead, so don't reach for this on every small function. Save it for work that would otherwise show up as a visible spike in the p99 lag number from step one. If you've got the event loop monitor running, you'll know exactly which endpoints need this treatment instead of wrapping everything in a worker "just in case."
The checklist to keep on hand
| Area | Diagnostic / Root Cause | Production Remediation |
|---|---|---|
| V8 Heap Growth | Uncollected closures & global Maps | Heap snapshots via Chrome DevTools / v8.writeHeapSnapshot() |
| Event Loop Lag | Synchronous crypto / large JSON parsing | Worker Threads (worker_threads) or async streaming |
| RxJS / Event Listeners | Unclosed subscriptions | takeUntil pattern & explicit module cleanup hooks |
| libuv Threadpool | File I/O & DNS bottleneck | Increase UV_THREADPOOL_SIZE=16 in container entrypoint |
None of these fixes are exotic. They're mostly discipline: measure lag before you optimize, tie every subscription to a teardown hook, cap your caches, and keep synchronous CPU work off the main thread. Do those four things and you'll catch most of what turns a clean load test into a 3 a.m. restart-loop page.
Accelerate your Backend & Performance Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Frequently Asked Questions
What causes memory leaks in NestJS applications?
The two most common causes are RxJS subscriptions that never unsubscribe, which keep request-scoped closures alive in memory, and unbounded in-memory caches like a plain JavaScript Map that grows forever instead of evicting old entries.
How do you measure event loop lag in Node.js?
Use the built-in perf_hooks module's monitorEventLoopDelay function, which samples the event loop on an interval and gives you percentile lag numbers (p50, p99) without adding an external dependency.
Should I use worker threads for every CPU-heavy task in Node.js?
No — only for genuinely blocking work like large JSON parsing, compression, or cryptographic hashing. Worker threads have spin-up overhead and message-passing cost, so using them for small, fast operations can add latency instead of removing it.
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Stripe Payments in React: 3D Secure, Apple Pay, and Surviving Network Drops
How to wire Stripe's Payment Element into a React app so 3D Secure, Apple Pay, and Google Pay don't leave you with orphaned orders or duplicate charges.
Your Vite Bundle Is Probably 1.5MB for No Good Reason
Fix slow LCP and INP in React + Vite apps with route-level code splitting, manual Rollup chunks, and smarter icon imports. Real config included.
MongoDB Change Streams in NestJS Keep Dropping Events. Here's the Fix
Change Streams look production-ready in a demo and fall apart on deploy day. Here's how to persist resume tokens, batch under load, and survive replica set elections in NestJS.