SuitsLearn LogoSuitsLearn

Node.js

Part 2 — Writing Production Code With the Event Loop in Mind

A practical guide to the patterns that quietly block the Node.js event loop in production: CPU-heavy operations, synchronous APIs, JSON processing, regular expression traps, large loops, memory pressure, and the async patterns that fix them.

By Ishan Wickremasuriya

Published 2026-06-1814 min read

Asynchronous ProgrammingBackend EngineeringEngineering LessonsEvent LoopJavaScript RuntimeNode.jsPerformance OptimizationProduction Systems

Part 1 explained how the event loop works — the call stack, libuv, the phases, microtasks and macrotasks. That knowledge is the theory.

This post is about what goes wrong when that theory meets a real codebase under real traffic.

None of the patterns below look dangerous on their own. None of them throw errors. None of them show up in a code review as obviously wrong. They simply occupy the call stack a little longer than they should — and at production scale, "a little longer" is the difference between a responsive API and a server that quietly stalls under load.


Avoiding Blocking the Event Loop

Recall the core rule from Part 1: the event loop can only process the next callback once the call stack is empty. Blocking the event loop means keeping something on that call stack for long enough that everything else — other requests, timers, I/O callbacks — has to wait.

The mental shift that matters here:

Asynchronous code is not automatically non-blocking code.

app.get("/process", async (req, res) => {
  const result = heavyComputation(data); // synchronous, CPU-bound
  res.json(result);
});

Wrapping this in an async function changes nothing about how heavyComputation executes. await only yields control when it's awaiting something that genuinely hands off to libuv — a database call, a file read, a network request. A synchronous function called inside an async function still runs to completion on the call stack, blocking everyone else.

Request arrives
      |
      v
heavyComputation() starts — pushed onto call stack
      |
      v
Call stack occupied for the full duration
      |
      v
Every other request, timer, and I/O callback waits
      |
      v
heavyComputation() finishes — stack clears
      |
      v
Other requests finally get processed

The rest of this post is a catalog of where heavyComputation() hides in code that doesn't look like it.


CPU-Heavy Operations

The clearest case is intentional heavy computation: image resizing, video transcoding, complex business calculations, cryptographic hashing with high cost factors.

const bcrypt = require("bcrypt");

app.post("/signup", async (req, res) => {
  const hash = bcrypt.hashSync(req.body.password, 14); // blocking
  // ...
});

bcrypt.hashSync with a high cost factor can take hundreds of milliseconds. During that time, the entire process — every other user's request — is frozen. The fix here is straightforward: use the async variant.

const hash = await bcrypt.hash(req.body.password, 14); // delegates to libuv's thread pool

The harder cases are the ones where CPU cost isn't obvious from the function name — which is most of the rest of this list.


Synchronous APIs

Node.js ships synchronous versions of many standard library functions, usually as a convenience for scripts and startup code — not for request handlers.

const fs = require("fs");

app.get("/config", (req, res) => {
  const data = fs.readFileSync("./config.json", "utf8"); // blocking
  res.json(JSON.parse(data));
});

readFileSync does not delegate to libuv's async I/O path. It blocks the call stack until the OS returns the file contents. For a small local config file this might be a few milliseconds — survivable, but still a tax on every request, and one that grows if the file grows or the disk is under load (network-mounted storage, for example).

fs.readFileSync()
      |
      v
Bypasses async I/O entirely
      |
      v
Call stack blocked until OS read completes

The fix is almost always mechanical:

const data = await fs.promises.readFile("./config.json", "utf8");

A useful rule of thumb: any Node.js core API with a Sync suffix — readFileSync, writeFileSync, execSync, existsSync — is a candidate for removal from any code that runs inside a request handler. They're fine at application startup, before the server is accepting traffic. They're a liability once it is.


JSON Processing Problems

JSON.parse and JSON.stringify look like utility functions. At small payload sizes, they are. At large payload sizes, they become a CPU-bound operation that runs entirely on the call stack — no async variant exists in the standard library.

app.get("/export", async (req, res) => {
  const records = await database.getAllRecords(); // async, fine
  const json = JSON.stringify(records);            // synchronous, can be expensive
  res.send(json);
});

The database call is non-blocking. The serialization step is not. For a result set of a few hundred rows, this is invisible. For a result set of hundreds of thousands of rows — an export endpoint, a reporting feature, an admin dashboard — JSON.stringify can occupy the call stack for hundreds of milliseconds or more, during which the entire server is unavailable to every other request.

Small payload   →  JSON.stringify  →  ~1ms   →  invisible
Large payload   →  JSON.stringify  →  500ms+ →  every other request waits

Common mitigations:

  • Streaming serialization — libraries like JSONStream or stream-json serialize incrementally instead of building the entire string in memory and on the stack at once
  • Pagination — return data in bounded chunks rather than one large payload
  • Worker Threads — move serialization of genuinely large payloads off the main thread entirely

Regular Expression Traps

This is the subtlest item on this list, because the danger isn't payload size — it's the shape of the input combined with the shape of the pattern.

Certain regular expression patterns exhibit catastrophic backtracking: for specific malicious or even accidental inputs, the regex engine's execution time grows exponentially with input length.

const isValidEmail = /^([a-zA-Z0-9]+)+@[a-zA-Z0-9.]+$/;

isValidEmail.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!");

The nested quantifier ([a-zA-Z0-9]+)+ is the problem. For a non-matching input of this shape, the regex engine tries an exponential number of ways to backtrack through the repeated groups before giving up.

Input length 20   →  milliseconds
Input length 30   →  seconds
Input length 40   →  the process is effectively frozen

This is a known attack vector — it's called a ReDoS (Regular Expression Denial of Service) — and unlike most performance issues on this list, it can be deliberately triggered by an attacker sending a single carefully crafted string to a public endpoint.

Mitigations:

  • Avoid nested quantifiers on overlapping character classes ((a+)+, (a*)* patterns)
  • Use established, audited libraries for common validation (email, URL) rather than hand-rolled regex
  • Set explicit timeouts or use libraries like re2, which guarantee linear-time matching by design
  • Test regex patterns against pathological inputs before shipping them on public-facing endpoints

Large Loops

Synchronous loops over large datasets are CPU work, in the most literal sense — and they don't need to look unusual to cause a problem.

app.get("/analytics", async (req, res) => {
  const orders = await database.getOrders(); // could be 500,000 rows

  let total = 0;
  for (const order of orders) {
    total += calculateComplexMetric(order); // runs 500,000 times, synchronously
  }

  res.json({ total });
});

Each iteration might take a fraction of a millisecond. Multiplied by half a million rows, the loop alone can occupy the call stack for seconds — during which nothing else in the process can run.

500,000 iterations × 0.01ms each = 5,000ms = 5 seconds blocked

This is one of the more common production surprises, because the code passes review easily — there's no obviously dangerous function call, just ordinary iteration. The danger is purely a function of scale, which means it often doesn't appear until the dataset grows well past what was tested in development.

Mitigations:

  • Move the computation to the database — aggregate functions (SUM, GROUP BY) are usually faster and don't touch the Node.js call stack at all
  • Chunk the loop — break work into smaller batches and yield back to the event loop between them using setImmediate or process.nextTick
  • Move to a Worker Thread — for computation that genuinely has to happen in JavaScript

Memory Pressure

Memory issues and event loop responsiveness are more connected than they first appear, because garbage collection itself runs on the same call stack as your application code.

app.get("/report", async (req, res) => {
  const allData = await database.getAllRecords(); // loads everything into memory at once
  const transformed = allData.map(transformRecord); // creates a second large array
  const filtered = transformed.filter(isRelevant);  // creates a third
  res.json(filtered);
});

Each .map() and .filter() call allocates a brand-new array. For large datasets, this pattern can create significant short-lived memory pressure — and when V8's garbage collector needs to run a major collection cycle to reclaim that memory, it can pause JavaScript execution entirely for a noticeable duration.

Large allocation spike
      |
      v
V8 garbage collector triggers a major GC cycle
      |
      v
JavaScript execution paused during collection
      |
      v
Event loop cannot process anything — including unrelated requests

This is why a memory-pressure problem in one part of an application can produce latency spikes in completely unrelated endpoints — GC pauses affect the entire process, not just the code that caused the allocation.

Mitigations:

  • Process large datasets in streams rather than loading everything into memory at once
  • Avoid chaining multiple array transformations that each allocate a new array — combine logic into a single pass where practical
  • Monitor heap usage and GC pause duration in production, not just response times

Async Patterns That Actually Help

The fixes across every section above share a common shape: get the work off the call stack, or break it into pieces small enough that the call stack is never occupied for long.

A few concrete patterns:

Yielding control during iteration

async function processInChunks(items, chunkSize = 1000) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    chunk.forEach(processItem);

    // Yield back to the event loop between chunks
    await new Promise(resolve => setImmediate(resolve));
  }
}

This doesn't make the total work faster — it makes the application responsive while that work happens, by giving other requests a chance to run between chunks.

Offloading to Worker Threads

const { Worker } = require("worker_threads");

function runHeavyTask(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./heavy-task.js", { workerData: data });
    worker.on("message", resolve);
    worker.on("error", reject);
  });
}

Worker Threads run on genuinely separate threads with their own call stack — true parallelism for CPU-bound work, at the cost of message-passing overhead to move data in and out.

Delegating to the database

The fastest fix for "large loop over data" is often: don't loop in Node.js at all. Push aggregation, filtering, and sorting into the database query itself.

These three patterns — chunking, Worker Threads, and pushing work elsewhere — cover the overwhelming majority of real production fixes for event loop blocking. Which one applies depends on whether the work can be parallelized, whether it needs to stay in the request lifecycle at all, and how much data is actually involved.


Summary: Part 2

PatternWhy It BlocksPrimary Fix
CPU-heavy operationsComputation runs directly on the call stackUse async variants; offload to Worker Threads
Synchronous APIs (*Sync)Bypass libuv's async I/O path entirelyUse the Promise-based equivalent
Large JSON processingNo async variant exists for JSON.parse/stringifyStream serialization; paginate
Regex with catastrophic backtrackingExponential-time matching for certain inputsAvoid nested quantifiers; use re2 or audited libraries
Large loopsLinear cost, but at scale becomes seconds of blockingChunk with yields; push to database; Worker Threads
Memory pressureGC pauses block the entire call stack, not just the allocating codeStream data; avoid chained allocations; monitor heap

Where This Leaves Us

Every pattern in this post is a variation on the same theme from Part 1: the event loop can only serve the next piece of work once the call stack is clear. None of these problems require unusual code — they require ordinary code meeting production-scale data, traffic, or input.

The harder question — and the one Part 3 exists to answer — is how you find these problems before they find you in an incident channel at 2am. Code review can catch the obvious cases. It cannot tell you that your /export endpoint is fine in staging with 200 rows and will block the event loop for four seconds in production with 200,000.

That requires actually measuring what the event loop is doing under real load — which is exactly where Part 3 starts.


What's Coming in Part 3

"Part 3 — Event Loop Performance Optimization Techniques"

With the failure patterns identified, Part 3 turns to detection and mitigation at the system level:

  • Measuring event loop lag — quantifying how long the event loop is delayed in processing the next callback, and why this number matters more than CPU usage alone
  • Monitoring — built-in and third-party tools for tracking event loop health in production
  • Profiling — finding exactly which function is occupying the call stack, using Node.js's native profiler
  • Worker Threads — a deeper look at when they're worth the overhead and when they aren't
  • Streams — processing data incrementally instead of loading it whole
  • Backpressure — what happens when a stream's consumer can't keep up with its producer
  • Batching — grouping work to reduce overhead without reintroducing blocking
  • Queue-based architecture — moving heavy work out of the request lifecycle entirely

If Part 2 was about recognizing the problem, Part 3 is about building systems that catch it automatically — before it becomes an incident.