Your NestJS API is responding slowly, but CPU utilization sits at a comfortable 20%. The database queries are fast, network latency is normal, but requests still queue up. The culprit isn't always where you think it is. Profiling the Node event loop reveals when your code is blocking the single-threaded JavaScript runtime, and that's often the real bottleneck.
Understanding Event Loop Blocking
Node.js runs JavaScript on a single thread, processing events in a loop. When your code blocks that thread—even for just a few milliseconds—every other operation waits. The problem is invisible in CPU graphs because blocking code still looks "light" from a CPU percentage perspective. You're not using much compute; you're just holding the thread hostage.
I learned this the hard way during a trading platform project. We had a REST endpoint that parsed and validated large order payloads. CPU was barely moving, but the p95 latency was consistently above 2 seconds for requests that objectively "should" take 50ms. The issue wasn't the validation logic itself—it was that blocking the event loop serialized all concurrent requests through a bottleneck.
Event loop lag is the delay between when an event is ready to be processed and when it actually gets processed. When you measure this delay, you're seeing how long your code held the thread.
Measure Event Loop Lag with perf_hooks
Node's built-in perf_hooks module provides monitorEventLoopDelay(), which measures blocking in milliseconds. This is your first diagnostic tool.
import { monitorEventLoopDelay } from 'perf_hooks';
export class EventLoopMonitor {
private monitor = monitorEventLoopDelay({ resolution: 10 });
start() {
this.monitor.enable();
}
getStats() {
const { min, max, mean, stddev, percentile } = this.monitor;
return {
min: Math.round(min / 1000) / 1000, // convert nanoseconds to ms
max: Math.round(max / 1000) / 1000,
mean: Math.round(mean / 1000) / 1000,
p99: Math.round(percentile(99) / 1000) / 1000,
p999: Math.round(percentile(99.9) / 1000) / 1000,
};
}
stop() {
this.monitor.disable();
}
}
Inject this into your application bootstrap:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const monitor = new EventLoopMonitor();
monitor.start();
// Expose metrics endpoint
app.get('/metrics/event-loop', (req, res) => {
res.json(monitor.getStats());
});
await app.listen(3000);
}
Load-test your API and check the metrics. If p99 event loop delay exceeds 10ms, you have blocking code. Anything over 50ms is critical. This number tells you how long requests are waiting just to have their turn on the JavaScript thread.
Find Blocking Code: Common Culprits
Once you know the event loop is blocked, where do you look? A few patterns show up repeatedly in production systems.
Large JSON.stringify Operations
Serializing a 5MB object to JSON is synchronous and blocks the thread.
// Bad: blocks the event loop
export class OrderService {
async exportOrders(count: number) {
const orders = await this.db.query(`SELECT * FROM orders LIMIT ${count}`);
const json = JSON.stringify(orders); // If orders is large, this blocks
return json;
}
}
// Better: stream to the response
async exportOrders(count: number, @Res() res: Response) {
const stream = this.db.stream(`SELECT * FROM orders LIMIT ${count}`);
res.setHeader('Content-Type', 'application/json');
res.write('[');
let first = true;
stream.on('data', (row) => {
if (!first) res.write(',');
res.write(JSON.stringify(row));
first = false;
});
stream.on('end', () => {
res.write(']');
res.end();
});
}
Synchronous Crypto or Hashing
Heavy cryptographic operations should not run on the main thread in a high-concurrency scenario.
import * as crypto from 'crypto';
// Bad: synchronous, blocks for each request
hashPassword(password: string): string {
const salt = crypto.randomBytesSync(16);
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
}
// Better: use async version or worker thread
async hashPassword(password: string): Promise {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16);
crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, derived) => {
if (err) reject(err);
resolve(derived.toString('hex'));
});
});
}
Complex Regexes and Large Loops
Processing a large dataset with nested loops or catastrophic regex backtracking blocks the thread.
// Bad: O(n²) loop on a large array
validateTransactions(transactions: Transaction[]) {
for (let i = 0; i < transactions.length; i++) {
for (let j = 0; j < transactions.length; j++) {
if (transactions[i].id === transactions[j].id && i !== j) {
throw new Error('Duplicate transaction');
}
}
}
}
// Better: use a Set for O(n) lookup
validateTransactions(transactions: Transaction[]) {
const seen = new Set();
for (const tx of transactions) {
if (seen.has(tx.id)) throw new Error('Duplicate transaction');
seen.add(tx.id);
}
}
Use Clinic.js and --inspect for Visualization
Measuring lag is one thing; knowing which function caused it is another. Clinic.js and Node's built-in inspector give you flame graphs showing exactly where time is spent.
Install Clinic.js:
npm install -g clinic
Run your application under observation:
clinic doctor -- node dist/main.js
Generate load while it runs, then Clinic.js will analyze the results and show you a detailed report of which functions consumed the most time and how they blocked the event loop.
Alternatively, use Node's built-in inspector for a flame graph:
node --prof dist/main.js
# Generate load...
# Stop the process (Ctrl+C)
node --prof-process isolate-*.log > profile.txt
Or use Chrome DevTools directly:
node --inspect dist/main.js
# Open chrome://inspect in Chrome
The flame graph shows stacks of function calls. If you see a tall, narrow tower in the graph, that function is blocking the event loop.
Fix Patterns: Stream, Paginate, Worker Threads
Streaming Large Payloads
Never load entire large datasets into memory and serialize them at once. Stream instead.
@Get('/export-csv')
exportCsv(@Res() res: Response) {
const query = this.db.stream(`SELECT * FROM users WHERE created_at > now() - interval '30 days'`);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="users.csv"');
const transform = csv.format({ headers: true });
query.pipe(transform).pipe(res);
}
Pagination and Batching
Process large operations in chunks instead of all at once.
async processMillionRecords() {
const pageSize = 1000;
let page = 0;
while (true) {
const batch = await this.db.query(
`SELECT * FROM records LIMIT ${pageSize} OFFSET ${page * pageSize}`
);
if (batch.length === 0) break;
// Process in chunks, allowing the event loop to handle other requests
await this.processBatch(batch);
// Yield control back
await new Promise(resolve => setImmediate(resolve));
page++;
}
}
Move CPU Work to Worker Threads
For genuinely heavy computation, offload to worker threads.
import { Worker } from 'worker_threads';
export class CryptoService {
private workerPool: Worker[] = [];
constructor() {
for (let i = 0; i < 4; i++) {
this.workerPool.push(new Worker('./crypto.worker.ts'));
}
}
async hashPasswordWithWorker(password: string): Promise {
const worker = this.workerPool[Math.floor(Math.random() * this.workerPool.length)];
return new Promise((resolve, reject) => {
worker.once('message', resolve);
worker.once('error', reject);
worker.postMessage({ password });
});
}
}
Common Mistakes
- Assuming low CPU = no problem: Blocking code can look cheap in CPU graphs but devastate latency. Always monitor event loop lag alongside CPU.
- Not testing under realistic concurrency: Blocking code might be fine with 5 concurrent requests but catastrophic with 500. Load-test appropriately.
- Using setImmediate() as a cure-all: Yielding control helps, but it doesn't fix fundamentally blocking operations. Combine it with streaming or worker threads.
- Ignoring third-party libraries: Heavy dependencies (some ORMs, XML parsers, image processors) can block silently. Profile them too.
- Optimizing the wrong thing: Get the flame graph first. Don't assume you know where the bottleneck is.
Key Takeaways
- Event loop lag is the true measure of JavaScript blocking—measure it with
monitorEventLoopDelay()and aim for sub-10ms p99 latency. - The usual suspects are large JSON serialization, synchronous crypto, complex regexes, and nested loops—search for those first.
- Use Clinic.js or Node's
--inspectflag to generate flame graphs and see exactly which functions are blocking. - Fix blocking code with three patterns: streaming for large data, pagination for batch processing, and worker threads for CPU-intensive work.
- Profile under realistic concurrent load, not just single requests—blocking becomes catastrophic at scale.






Comments
Loading comments…