Blocking I/O in Async Systems — The Node.js Event Loop Killer

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Node.js is single-threaded. One blocking operation does not slow down one request — it freezes every request waiting on that thread. While a 200ms synchronous CPU computation runs, every other request, health check, and timeout callback waits behind it. This is the event loop's fatal flaw when treated carelessly.

How the Event Loop Works

Node.js achieves concurrency by yielding the thread during I/O. When your code calls await db.query(...), Node parks that task and handles other requests while the database is working. The moment you introduce synchronous CPU work — a tight loop, a large JSON.parse, a synchronous file read — Node cannot yield, and everything else waits.

Event loop (single thread):
 
Request A  → await db.query()    → yields → response sent
Request B  → await fetch()       → yields → response sent
Request C  → JSON.parse(10MB)    → BLOCKS 200ms → everything else waits

During Request C's 200ms block: Request A and B cannot receive I/O callbacks, new connections queue up, health checks time out.

Blocking Pattern 1: Large JSON Operations

// BAD: parsing a 10MB response blocks the event loop ~200ms
app.get('/large-data', async (req, res) => {
  const raw = await fetchLargeApiResponse();
  const parsed = JSON.parse(raw); // BLOCKS
  res.json(parsed);
});
 
// GOOD: stream JSON parsing, yield to event loop between rows
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
 
app.get('/large-data', async (req, res) => {
  const response = await fetch('https://api.example.com/huge');
  res.setHeader('Content-Type', 'application/json');
  res.write('[');
  let first = true;
 
  const jsonStream = response.body.pipe(parser()).pipe(streamArray());
 
  jsonStream.on('data', ({ value }) => {
    if (!first) res.write(',');
    res.write(JSON.stringify(value));
    first = false;
  });
 
  jsonStream.on('end', () => {
    res.write(']');
    res.end();
  });
});

Blocking Pattern 2: Synchronous File Operations

// BAD: fs.readFileSync blocks the thread until the file is fully read
app.get('/file', (req, res) => {
  const data = require('fs').readFileSync('large-file.txt'); // BLOCKS
  res.send(data);
});
 
// GOOD: async read
app.get('/file', async (req, res) => {
  const data = await require('fs').promises.readFile('large-file.txt');
  res.send(data);
});
 
// BETTER: stream large files to avoid holding the entire file in memory
app.get('/file', (req, res) => {
  require('fs').createReadStream('large-file.txt').pipe(res);
});

The fs.readFileSync, fs.writeFileSync, fs.existsSync, and path.resolve family are all synchronous and safe only in startup code, never inside request handlers.

Blocking Pattern 3: CPU-Heavy Loops

// BAD: iterating 1M items blocks for 500ms+
app.get('/process', async (req, res) => {
  const items = await db.fetchMillionItems();
  let result = 0;
  for (const item of items) {
    result += expensiveComputation(item); // 500ms total, all blocking
  }
  res.json({ result });
});
 
// GOOD option 1: offload to a worker thread
import { Worker } from 'worker_threads';
 
function runInWorker(workerFile, data) {
  return new Promise((resolve, reject) => {
    const w = new Worker(workerFile, { workerData: data });
    w.on('message', resolve);
    w.on('error', reject);
  });
}
 
app.get('/process', async (req, res) => {
  const items = await db.fetchMillionItems();
  const result = await runInWorker('./computation-worker.js', { items });
  res.json({ result });
});
 
// GOOD option 2: chunk with setImmediate to yield between batches
app.get('/process', async (req, res) => {
  const items = await db.fetchMillionItems();
  let result = 0;
  const CHUNK = 1000;
 
  for (let i = 0; i < items.length; i += CHUNK) {
    for (const item of items.slice(i, i + CHUNK)) {
      result += expensiveComputation(item);
    }
    // Yield to event loop between chunks
    await new Promise((r) => setImmediate(r));
  }
 
  res.json({ result });
});

Blocking Pattern 4: Synchronous Crypto

// BAD: synchronous randomBytes blocks the thread
app.post('/token', (req, res) => {
  const token = require('crypto').randomBytes(32).toString('hex'); // BLOCKS
  res.json({ token });
});
 
// GOOD: async callback form
app.post('/token', (req, res) => {
  require('crypto').randomBytes(32, (err, buf) => {
    if (err) return res.status(500).json({ error: err.message });
    res.json({ token: buf.toString('hex') });
  });
});

Blocking Pattern 5: Catastrophic Regex

// BAD: complex regex on large input can backtrack for seconds
app.post('/validate', (req, res) => {
  const { content } = req.body; // could be 1MB
  const isValid = /^(a+)+$/.test(content); // catastrophic backtracking
  res.json({ isValid });
});
 
// GOOD: enforce input size limit before any regex
app.post('/validate', (req, res) => {
  const { content } = req.body;
  if (content.length > 10000) {
    return res.status(400).json({ error: 'Input too large' });
  }
  const isValid = /^[a-z]+$/.test(content); // safe, bounded regex
  res.json({ isValid });
});

Measuring Event Loop Lag

Event loop lag is the time between scheduling a callback and when it actually runs. In a healthy server it is under 5ms.

// Basic lag measurement
function measureEventLoopLag() {
  return new Promise((resolve) => {
    const start = process.hrtime.bigint();
    setImmediate(() => {
      const lag = Number(process.hrtime.bigint() - start) / 1_000_000;
      resolve(lag);
    });
  });
}
 
setInterval(async () => {
  const lag = await measureEventLoopLag();
  if (lag > 50) console.warn(`Event loop lag: ${lag.toFixed(1)}ms`);
}, 1000);
 
// Built-in histogram (Node.js 16+)
import { monitorEventLoopDelay } from 'perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
 
setInterval(() => {
  console.log({
    mean: (h.mean / 1e6).toFixed(2) + 'ms',
    p99:  (h.percentile(99) / 1e6).toFixed(2) + 'ms',
    max:  (h.max / 1e6).toFixed(2) + 'ms',
  });
}, 10_000);

Detecting Blocking Code in CI

# clinic.js generates flame graphs that highlight synchronous work
npm install -g clinic
 
# Flame graph showing where CPU time is spent
clinic flame -- node app.js
 
# Doctor automatically diagnoses common issues including blocking I/O
clinic doctor -- node app.js
 
# Run with realistic load while profiling
npx autocannon -c 100 -d 30 http://localhost:3000/api

A flame graph that shows a wide, flat bar at the top of the stack with no async boundaries is the visual signature of blocking I/O.

Quick Reference

OperationBlocking?Fix
fs.readFileSync()Yesfs.promises.readFile()
JSON.parse(bigString)Yesstream-json parser
crypto.randomBytes(n) syncYescallback or promise form
for loop over 100k+ itemsYesworker thread or chunked setImmediate
Complex regex on large inputYesinput size limit first
child_process.execSync()Yeschild_process.exec()
await db.query()Nosafe
await fetch()Nosafe

Key Takeaways

  • Node.js is single-threaded: one blocking operation stops every concurrent request, not just the one that caused it
  • fs.readFileSync, JSON.parse on large payloads, and synchronous crypto are the most common accidental blockers
  • Use fs.promises.readFile or createReadStream instead of any *Sync file API inside request handlers
  • Offload CPU-heavy loops to worker threads using the worker_threads module; alternatively, chunk work with setImmediate to yield between batches
  • Enforce input size limits before running any regex to prevent catastrophic backtracking
  • Measure event loop lag with monitorEventLoopDelay in production; alert when p99 exceeds 50ms
  • Use clinic flame in CI to identify blocking operations before they reach production
  • Clock tolerance for acceptable event loop lag: under 5ms is healthy, 10–50ms is degraded, above 50ms is critical

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading