Node.js Performance Tuning: Event Loop, Worker Threads, and Memory Profiling

Node.js Performance Starts With Understanding the Event Loop

Node.js runs your JavaScript on a single thread. That thread manages the event loop — a cycle that processes callbacks, I/O events, timers, and microtasks. When people say Node.js is "single-threaded," they mean your application code runs on one thread. I/O operations and some built-in functions use a thread pool (libuv's pool, 4 threads by default).

The critical insight: anything that blocks the event loop blocks everything. While your code is crunching a 10MB JSON parse, no HTTP requests are being handled, no database callbacks are firing, nothing happens. The event loop is stuck.

Diagnosing Event Loop Blocking

The --inspect flag enables the Chrome DevTools protocol. Connect Chrome to chrome://inspect and you can profile your Node.js process just like a browser tab — CPU profiles, heap snapshots, and the performance timeline.

For production monitoring without the overhead of a debugger, use event loop lag measurement:

const start = process.hrtime.bigint();
setImmediate(() => {
  const lag = Number(process.hrtime.bigint() - start) / 1_000_000;
  if (lag > 100) {
    console.warn(`Event loop lag: ${lag.toFixed(1)}ms`);
  }
});

// Or use the built-in perf_hooks
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

setInterval(() => {
  console.log(`Event loop p99: ${(h.percentile(99) / 1e6).toFixed(1)}ms`);
  h.reset();
}, 5000);

Healthy event loop lag should be under 10ms. If p99 is above 50ms, you've got blocking operations that need to be moved off the main thread.

Worker Threads for CPU-Bound Work

Node.js has had Worker Threads since v12. They're true OS threads with their own V8 isolate — they don't share memory with the main thread (except SharedArrayBuffer) and they don't block the event loop.

// worker.js
const { parentPort, workerData } = require('worker_threads');

function heavyComputation(data) {
  // CPU-intensive work: JSON parsing, encryption, image processing, etc.
  let result = 0;
  for (let i = 0; i < data.iterations; i++) {
    result += Math.sqrt(i) * Math.sin(i);
  }
  return result;
}

parentPort.postMessage(heavyComputation(workerData));

// main.js
const { Worker } = require('worker_threads');

function runWorker(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker.js', { workerData: data });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

// Non-blocking: event loop continues while worker crunches numbers
app.get('/compute', async (req, res) => {
  const result = await runWorker({ iterations: 10_000_000 });
  res.json({ result });
});

Creating a Worker has overhead (~30-50ms startup). For frequent tasks, use a worker pool like piscina or workerpool that keeps threads alive between tasks:

const Piscina = require('piscina');
const pool = new Piscina({
  filename: './worker.js',
  minThreads: 2,
  maxThreads: 4,
});

app.get('/compute', async (req, res) => {
  const result = await pool.run({ iterations: 10_000_000 });
  res.json({ result });
});

Memory Profiling and Leak Detection

V8 has a generational garbage collector. New objects go in "young generation" (small, collected frequently). Long-lived objects get promoted to "old generation" (larger, collected less often). Full GC pauses — where V8 scans the entire old generation — are the ones that cause visible latency spikes.

To take a heap snapshot in production:

const v8 = require('v8');
const fs = require('fs');

function dumpHeap() {
  const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
  const snapshotStream = v8.writeHeapSnapshot(filename);
  console.log(`Heap snapshot written to ${filename}`);
  return filename;
}

// Trigger via HTTP endpoint (protect this in production!)
app.post('/debug/heap-dump', (req, res) => {
  const file = dumpHeap();
  res.json({ file });
});

Load the .heapsnapshot file in Chrome DevTools (Memory tab) to analyze object retention. Look for:

  • Growing arrays or Maps — often event listeners that aren't being removed, or caches without eviction
  • Detached DOM trees — in server-side rendering scenarios (Next.js, etc.)
  • Closures holding references — callbacks that capture large objects in their closure scope

Common Memory Leak Patterns

// LEAK: global array that grows forever
const requestLog = [];
app.use((req, res, next) => {
  requestLog.push({ url: req.url, time: Date.now() });
  next();
});

// LEAK: event listeners never removed
class Connection {
  constructor(socket) {
    socket.on('data', this.handleData.bind(this));
    // If Connection objects are created/destroyed frequently,
    // the listener stays on the socket
  }
}

Monitor process.memoryUsage() over time. If heapUsed trends upward without plateauing, you've got a leak. The --max-old-space-size flag (default ~1.5GB on 64-bit) sets the heap limit — Node.js crashes with an OOM error when it's exceeded.

Stream Processing for Large Data

Buffering an entire file or HTTP response in memory defeats the purpose of Node.js's event-driven architecture. Streams process data in chunks:

const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
const { createGzip } = require('zlib');

// Process a 2GB file with ~64KB of memory
await pipeline(
  createReadStream('input.csv'),
  createGzip(),
  createWriteStream('output.csv.gz')
);

For HTTP responses, pipe database query results directly to the response instead of loading everything into memory:

app.get('/export', (req, res) => {
  res.setHeader('Content-Type', 'text/csv');
  const cursor = db.collection('users').find().stream();
  cursor.pipe(new CSVTransform()).pipe(res);
});

Quick Wins for Existing Applications

  • Increase UV_THREADPOOL_SIZE if you do heavy file I/O or DNS lookups: UV_THREADPOOL_SIZE=16 node server.js (default is 4)
  • Use JSON.parse streaming for large JSON payloads — the stream-json package processes JSON token-by-token instead of buffering the entire string
  • Enable HTTP keep-alive on outbound requests: new http.Agent({ keepAlive: true }) reuses TCP connections instead of opening a new one per request
  • Set --max-semi-space-size=64 for high-allocation-rate services (many short-lived objects). Default young generation is 16MB; increasing it reduces GC frequency at the cost of slightly longer individual pauses.
  • Profile before optimizing. clinic.js (from NearForm) generates flame graphs, event loop analysis, and I/O recommendations automatically.