Skip to content
ExponaBLOG
NESTJS · DAY 072 / 100

Profiling the Node event loop: a practical NestJS guide

When your API is slow but the CPU looks fine.

Expona Admin8 min read

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.

typescript
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:

typescript
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.

typescript
// 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.

typescript
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.

typescript
// 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:

typescript
npm install -g clinic

Run your application under observation:

typescript
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:

typescript
node --prof dist/main.js
# Generate load...
# Stop the process (Ctrl+C)

node --prof-process isolate-*.log > profile.txt

Or use Chrome DevTools directly:

typescript
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.

typescript
@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.

typescript
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.

typescript
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 --inspect flag 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles