DevLift
Back to Blog

Blocking the Event Loop: The Synchronous Operation That Freezes Your Node.js Server

Your endpoint is async but it still freezes the server. Here's the synchronous CPU work pattern that blocks Node's event loop and how senior devs catch it in review.

Admin
August 2, 20268 min read2 views

Blocking the Event Loop: The Synchronous Operation That Freezes Your Node.js Server

The failure mode looks like this. Your API starts timing out for everyone at once, every time somebody triggers the CSV export. Not the export — the other endpoints. /api/dashboard, /api/user/me, the health check. All of them stall, then recover the moment the export finishes.

No memory spike. No database saturation. One CPU core pegged briefly, then normal. It reads like a traffic spike, except the traffic didn't change.

The culprit is a synchronous JSON.stringify over a large result set inside a request handler. A CPU profile (node --cpu-prof, or perf_hooks.monitorEventLoopDelay if you just want the tail latency) puts it in one frame immediately, but you have to know to go looking, because nothing in the code reads as suspicious.

This is the event loop blocking bug. It's one of the few Node.js mistakes that doesn't just make your endpoint slow — it makes every endpoint slow. And it's almost invisible in code review because the code looks async.

The Mistake

Node.js runs your JavaScript on a single thread. That thread drives the event loop — it picks up I/O callbacks, runs timers, processes incoming requests. When you await a database query, Node yields control back to the loop while waiting for the database. Other requests get processed. That's the whole model.

The trap: await only yields during I/O waits. It doesn't yield during CPU work.

// This looks async. It is not safe.
export async function GET(req: Request) {
  const orgId = new URL(req.url).searchParams.get('orgId');
  const users = await prisma.user.findMany({ where: { orgId } }); // yields here, fine
 
  const csv = users
    .map(u => `${u.name},${u.email},${u.createdAt}`)
    .join('\n'); // 40,000 string concatenations, no yield
 
  return new Response(csv, { headers: { 'Content-Type': 'text/csv' } });
}

The await prisma.user.findMany yields properly. But then the .map().join() runs synchronously on the main thread — 42ms for 40,000 rows on the box I measured this on, and every other request queued behind it waits that long. Not "is slower." Literally waits — the event loop is occupied. Forty milliseconds is survivable; the problem is that the number is a function of how much data the caller happens to own, and nothing in the code bounds it.

The Node.js docs call this precisely — the guide is literally titled "Don't Block the Event Loop (or the Worker Pool)" — and the rule it states is: "If your callback takes a constant number of steps no matter what its arguments are, then you'll always give every pending client a fair turn." The moment you do O(n) work where n is user data and n is unbounded, you've broken that.

Why It Looks Fine in Code Review

Three things make this easy to miss:

The async keyword is camouflage. A function marked async just means it returns a promise. It doesn't make synchronous operations inside it non-blocking. Reviewers see async function and mentally file it as "safe."

Dev datasets are tiny. Your test database has 50 users. The .map().join() on 50 items takes microseconds. The exact same code on 50,000 production rows takes seconds.

There's no obvious signal. No readFileSync. No execSync. Just normal JS — a loop, a string operation, a sort — the kind of code you write everywhere without thinking.

Real-World Examples

Example 1: bcrypt.hashSync in a registration handler

Bcrypt is deliberately slow to resist brute-force attacks. The sync version runs on the main thread.

// Before: hashing blocks the event loop for ~500ms per call
export async function POST(req: Request) {
  const { email, password } = await req.json();
 
  const hash = bcrypt.hashSync(password, 12); // ~300ms of dead event loop at cost 12
 
  const user = await prisma.user.create({
    data: { email, passwordHash: hash },
  });
 
  return Response.json({ userId: user.id });
}

At cost factor 12, bcryptjs.hashSync measured 296ms on Node 22.22.3 (cost 10 was 128ms — the doubling is the point of the cost factor). During that window your server handles exactly zero other requests. Five concurrent registrations and the fifth one waits about a second and a half for the event loop, before the database is even involved.

// After: async bcrypt yields during the hashing work
export async function POST(req: Request) {
  const { email, password } = await req.json();
 
  const hash = await bcrypt.hash(password, 12); // yields between chunks of work
 
  const user = await prisma.user.create({
    data: { email, passwordHash: hash },
  });
 
  return Response.json({ userId: user.id });
}

Dropping the Sync helps, but how it helps depends on which package you have, and this is worth getting right because the two behave differently.

The native bcrypt package really does hand the work to libuv's thread pool — the event loop is free for the whole duration. bcryptjs, the pure-JavaScript implementation most projects actually depend on, has no thread to hand anything to. It splits the rounds into batches and yields between them with setImmediate. Better, but not free. Measured with monitorEventLoopDelay on Node 22.22.3, bcryptjs at cost 12:

bcryptjs.hashSync(pw, 12)     wall=305ms  worst loop delay = 306.7ms
await bcryptjs.hash(pw, 12)   wall=290ms  worst loop delay = 101.7ms

A 3x improvement in the worst-case stall, not elimination of it. If you're on bcryptjs and 100ms of tail latency matters, the async call isn't the whole answer — a worker thread or a queue is.

Example 2: fs.readFileSync on hot paths

Config files, email templates, static JSON — developers often read them synchronously. Works fine in a CLI or a build script. Disaster in a request handler.

// Before: reads template file from disk on every request
export async function sendWelcomeEmail(userId: string) {
  const template = fs.readFileSync(
    path.join(process.cwd(), 'templates/welcome.html'),
    'utf-8'
  );
 
  const user = await prisma.user.findUnique({ where: { id: userId } });
  const html = template.replace('{{name}}', user.name);
 
  await resend.emails.send({ to: user.email, html, subject: 'Welcome!' });
}

The readFileSync blocks for as long as the disk takes to return the file. Typically milliseconds — until your server is on a busy NFS mount, or the file gets large, or you're on a cloud VM with slow disk throughput. The fix is either reading async or, better, reading once at startup:

// After: read template once at module load time
const WELCOME_TEMPLATE = fs.readFileSync(
  path.join(process.cwd(), 'templates/welcome.html'),
  'utf-8'
);
 
export async function sendWelcomeEmail(userId: string) {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  const html = WELCOME_TEMPLATE.replace('{{name}}', user.name);
  await resend.emails.send({ to: user.email, html, subject: 'Welcome!' });
}

Reading at module initialization (top-level, before the server starts) is fine — nothing is blocked yet. Module loading is synchronous by design. Just don't read on every request.

Example 3: Large JSON.stringify in export endpoints

This is the incident that opened this article. Export and reporting endpoints often serialize large result sets.

// Before: stringify 40k rows synchronously
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const orgId = searchParams.get('orgId');
 
  const orders = await prisma.order.findMany({
    where: { orgId },
    include: { lineItems: true, customer: true },
  });
 
  // orders might be 40,000 records with nested objects
  const payload = JSON.stringify({ orders, exportedAt: new Date() });
 
  return new Response(payload, {
    headers: { 'Content-Type': 'application/json' },
  });
}

How bad is that really? I measured it rather than guess, because this is exactly the kind of number that gets inflated in blog posts. Serialising 40,000 orders with two line items and a nested customer each — 9.1MB of JSON — took 68ms on Node 22.22.3. Not seconds. But it scales linearly, and the payload does too:

 40,000 rows ->   9MB   stringify  68ms   parse   76ms
200,000 rows ->  46MB   stringify 368ms   parse  514ms
400,000 rows ->  94MB   stringify 723ms   parse 1119ms

The Node.js docs put a 50MB string at 0.7 seconds to stringify and 1.3 seconds to parse, which lines up with the middle row on faster hardware. So: 40k rows is a 68ms stall, and 68ms of dead event loop on a hot endpoint is already a real p99 problem. 400k rows — a plausible "export everything" request from your biggest customer — is close to a second, plus a second more on whoever parses it. Both are worth fixing; only one of them is dramatic.

The instrumented version of the same thing is more useful than the raw timing. Running that stringify once per 10ms tick and watching perf_hooks.monitorEventLoopDelay:

idle (baseline)                    loop delay p50=6.39ms p99= 9.27ms max=11.57ms
JSON.stringify(40k rows) per turn  loop delay p50=5.38ms p99=71.43ms max=71.43ms
40k-row .map().join() per turn     loop delay p50=5.98ms p99=47.97ms max=65.18ms

The median is untouched. The tail is 7x worse. That's the signature of event-loop blocking in a metrics dashboard: p50 flat, p99 spiking whenever the expensive endpoint is hit.

// After: stream the response instead of buffering
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const orgId = searchParams.get('orgId');
 
  // A ReadableStream used as a Response body must yield bytes, not strings.
  const encoder = new TextEncoder();
 
  // Use cursor pagination to stream in batches
  const stream = new ReadableStream({
    async start(controller) {
      const push = (s: string) => controller.enqueue(encoder.encode(s));
 
      push('{"orders":[');
      let cursor: string | undefined;
      let first = true;
 
      while (true) {
        const batch = await prisma.order.findMany({
          where: { orgId },
          take: 500,
          orderBy: { id: 'asc' },
          ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
          include: { lineItems: true, customer: true },
        });
 
        if (batch.length === 0) break;
 
        for (const order of batch) {
          if (!first) push(',');
          push(JSON.stringify(order)); // small chunks, fast
          first = false;
        }
 
        cursor = batch[batch.length - 1].id;
        if (batch.length < 500) break;
      }
 
      push(']}');
      controller.close();
    },
  });
 
  return new Response(stream, {
    headers: { 'Content-Type': 'application/json' },
  });
}
⚠️

The TextEncoder isn't decoration. Enqueue a plain string into a ReadableStream and hand it to new Response(...) and it type-checks, starts fine, and then throws TypeError: Received non-Uint8Array chunk the moment the body is consumed — which on a route handler means a 500 in production and nothing at all in a unit test that only inspects the returned object. Confirmed on Node 22.22.3.

Each JSON.stringify(order) is one record, not 40,000. The event loop yields between batches (each await prisma.order.findMany is an I/O yield). Other requests get turns. The export still happens; it just doesn't hold everyone else hostage.

Example 4: Vulnerable regex in input validation

This one is subtle and gets missed in review constantly. Certain regex patterns have exponential worst-case complexity — known as ReDoS (Regular Expression Denial of Service).

A nested quantifier is a necessary condition, not a sufficient one. What actually makes a pattern explode is ambiguity: the same input has to be splittable across the outer repetition in exponentially many ways. Get that wrong and you'll flag safe regexes while shipping vulnerable ones, so it's worth being precise.

// SAFE, despite the nested quantifier
const looksScary = /^(\/[a-zA-Z0-9_-]+)+$/;
 
// CATASTROPHIC — same structure, one difference that matters
const actuallyBad = /^([a-zA-Z0-9_-]+\/?)+$/;

The first one is fine. Every repetition of the group must start with a literal /, so there's exactly one way to partition any input — no ambiguity, no backtracking blowup. Measured on Node 22.22.3, non-matching inputs of every shape I threw at it stayed under a tenth of a millisecond:

/^(\/[a-zA-Z0-9_-]+)+$/  vs  '/'.repeat(20000) + '!'       ->  0.07ms
/^(\/[a-zA-Z0-9_-]+)+$/  vs  '/' + 'a'.repeat(5000) + '!'  ->  0.03ms
/^(\/[a-zA-Z0-9_-]+)+$/  vs  '/a'.repeat(5000) + '!'       ->  0.11ms

The second one is the real bug. Making the separator optional (\/?) means abcd can be consumed as one group, or two, or three — and on a non-matching input the engine tries all of them. Same machine, same run:

/^([a-zA-Z0-9_-]+\/?)+$/  vs  'a'.repeat(18) + '!'  ->     9.9ms
/^([a-zA-Z0-9_-]+\/?)+$/  vs  'a'.repeat(22) + '!'  ->    28.6ms
/^([a-zA-Z0-9_-]+\/?)+$/  vs  'a'.repeat(26) + '!'  ->   443.8ms
/^([a-zA-Z0-9_-]+\/?)+$/  vs  'a'.repeat(28) + '!'  ->  1770.6ms

Twenty-eight bytes of a and one !. Four more characters and it's seven seconds; four more and it's half a minute. This is what a ReDoS payload looks like — it doesn't need to be long, and it fits in a query string.

// Before: optional separator inside a repeated group
function validatePath(input: string): boolean {
  return /^([a-zA-Z0-9_-]+\/?)+$/.test(input);
}
 
// Route that validates user-supplied paths
app.get('/api/resolve', (req, res) => {
  if (!validatePath(req.query.path as string)) {
    return res.status(400).json({ error: 'Invalid path' });
  }
  // ...
});
// After: split first, then match each segment with a regex that can't backtrack
function validatePath(input: string): boolean {
  if (input.length > 500) return false;
  return input.split('/').every(segment =>
    segment === '' || /^[a-zA-Z0-9_-]+$/.test(segment)
  );
}

split does the partitioning deterministically, so the per-segment regex has a single unambiguous input and nothing to backtrack over. Same validation, linear time. Note the length cap too — it's cheap and it bounds the damage of whatever you got wrong.

If you need genuinely complex patterns over untrusted input, node-re2 binds Google's RE2 engine, which guarantees linear-time matching by construction at the cost of backreferences and lookaround.

The Fix: Know Your Offload Options

When you genuinely need CPU work in a server, you have three options:

Worker Threads (Node.js 12+): For pure computation — image resizing, PDF generation, heavy crypto, complex data transforms.

import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
 
// This file is both the main module and the worker script.
if (!isMainThread) {
  const result = heavyComputation(workerData.input);
  parentPort?.postMessage(result);
}
 
// main thread — non-blocking
function runInWorker(input: unknown): Promise<unknown> {
  return new Promise((resolve, reject) => {
    // NOT __filename: that identifier does not exist in an ES module and you get
    // `ReferenceError: __filename is not defined` at runtime.
    const worker = new Worker(new URL(import.meta.url), { workerData: { input } });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

Child Process: For running external binaries (ImageMagick, ffmpeg, Python scripts). child_process.exec is async; execSync is not.

Push to a queue: For work that doesn't need to complete in the request cycle — background jobs, notifications, reports. BullMQ + Redis is the standard Node.js pattern. The request completes immediately; the work happens in a separate worker process.

How to Catch This in Code Review

Search for the Sync suffix: readFileSync, writeFileSync, pbkdf2Sync, randomFillSync, deflateSync, spawnSync, execSync. Any of these in a request handler is a red flag. In module initialization (outside request handlers), they're usually fine.

Question large data transformations: When a function loads potentially-large result sets and then processes them with .map(), .reduce(), .sort(), .filter() — ask "what's the max size of this array?" If the answer is "depends on how much data the user has," it needs a limit or offloading.

Flag ambiguous nested quantifiers in user-facing regex: (a+)+, ([ab]+)+, (\w+\s?)* — a repeated group whose body can match the same text in more than one way. A nested quantifier alone isn't enough; if every repetition is anchored by a mandatory literal, there's only one possible partition and no blowup. When in doubt, time it against 'a'.repeat(30) + '!' — a vulnerable pattern will be obvious immediately.

Read placement matters: fs.readFileSync at module top level? Fine. Inside a request handler or middleware that runs on every request? Not fine.

// PR comment: "This fs.readFileSync is inside the handler — will block on every request.
// If the file doesn't change at runtime, move it to module-level initialization."
 
export async function handler(req: Request) {
  const config = fs.readFileSync('./config.json'); // ← flag this
  // ...
}
🚨

The async keyword does not make CPU work non-blocking — only I/O operations yield the event loop. Any synchronous computation in a request handler blocks every other request until it completes.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read