0x55aa
← Back to Blog

🎡 The Node.js Event Loop: Stop Blocking the Bouncer

6 min read

🎡 The Node.js Event Loop: Stop Blocking the Bouncer

Imagine a nightclub. One bouncer. Hundreds of people wanting in. The bouncer checks IDs one at a time, but he's fast — lightning fast. As long as nobody holds up the line with a 20-minute argument about their expired driver's license, the queue flows smoothly and everyone has a good time.

That bouncer? That's your Node.js event loop.

And that person blocking the line with a 20-minute argument? That's your JSON.parse() call on a 50MB payload. We'll get to that.

What Even Is the Event Loop?

Node.js runs on a single thread. That sounds like a terrible idea until you realize that most of what a backend does is wait — waiting for a database query, waiting for a file to read, waiting for an HTTP response. While it waits, it doesn't need to sit there doing nothing like a developer staring at a build log.

The event loop lets Node.js register callbacks for those async operations, hand them off to the OS (via libuv), and immediately move on to the next request. When the OS says "hey, your database query is done," the event loop picks up the callback and executes it.

The result: a single-threaded server that can handle thousands of concurrent connections — as long as you don't block the loop.

The Six Phases (In Plain English)

The event loop has six phases, but honestly, you only need to care about three of them day-to-day:

  1. Timers — Runs setTimeout and setInterval callbacks whose delay has elapsed.
  2. I/O callbacks — Handles most async callbacks (network, file system, etc.).
  3. Poll — Retrieves new I/O events; this is where Node spends most of its time waiting.
  4. Check — Runs setImmediate callbacks.
  5. Close callbacks — Cleanup stuff like socket.on('close', ...).

And then there are microtasksPromise.then() and process.nextTick() — which cut the line entirely. They run between every phase transition. This is why resolved promises feel "instant."

console.log('1 - synchronous');

setTimeout(() => console.log('2 - setTimeout'), 0);

Promise.resolve().then(() => console.log('3 - promise microtask'));

process.nextTick(() => console.log('4 - nextTick microtask'));

console.log('5 - synchronous');

// Output order:
// 1 - synchronous
// 5 - synchronous
// 4 - nextTick microtask
// 3 - promise microtask
// 2 - setTimeout

nextTick fires before promises, promises fire before timers. Memorize this and you'll win every JavaScript trivia night at your local dev meetup (if that's your thing).

What "Blocking the Event Loop" Actually Looks Like

Here's the mistake most people make when they're new to Node.js — treating it like any other synchronous language:

// ❌ This is the guy arguing about his expired ID
app.get('/process', (req, res) => {
  const data = req.body.payload; // imagine this is 10MB of JSON

  // This is synchronous and CPU-bound — it freezes the entire server
  const parsed = JSON.parse(data);
  const result = parsed.items.map(item => heavyTransformation(item));

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

function heavyTransformation(item) {
  // Imagine some expensive computation here
  let sum = 0;
  for (let i = 0; i < 1_000_000; i++) sum += item.value * i;
  return sum;
}

While that endpoint is running, every other request to your server is frozen. Queued. Waiting. Your health check endpoint times out. Your metrics endpoint times out. That one PM who decided to test your API manually at 2pm on a Friday? They're having a bad day.

The Fix: Offload Heavy Work

For CPU-intensive tasks, you have two good options:

Option 1: Worker Threads — Node's built-in way to run JavaScript on a separate thread.

const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

// In your route handler:
app.get('/process', (req, res) => {
  const worker = new Worker('./heavy-worker.js', {
    workerData: { payload: req.body.payload }
  });

  worker.on('message', (result) => res.json({ result }));
  worker.on('error', (err) => res.status(500).json({ error: err.message }));
});

// heavy-worker.js
const { workerData, parentPort } = require('worker_threads');
const parsed = JSON.parse(workerData.payload);
const result = parsed.items.map(item => heavyTransformation(item));
parentPort.postMessage(result);

The main event loop is now free. The heavy work happens in a separate thread that can't block incoming requests.

Option 2: Queue it — For truly long-running jobs, don't make the HTTP request wait at all. Accept the job, return a job ID immediately, process it in the background, and let the client poll or receive a webhook when it's done. This is how every serious data pipeline works.

Quick Wins to Stop Blocking Right Now

  • Never use synchronous fs methods in a server context. fs.readFileSync blocks. fs.readFile (callback or promise) does not.
  • Be careful with large JSON. JSON.parse on huge payloads is synchronous and CPU-bound. Stream it with JSONStream if it's truly massive.
  • Use setImmediate to yield. If you have a loop that processes thousands of items, you can yield back to the event loop periodically with setImmediate(() => continueProcessing()).
  • Watch your crypto. crypto.pbkdf2Sync is famously event-loop-killing. Use the async version.

How to Catch Blockers Before They Bite

The --inspect flag + Chrome DevTools CPU profiler is your best friend. Record a profile, look for long synchronous call stacks on the main thread. Anything over 10ms is worth investigating; anything over 100ms is actively hurting your users.

There's also the clinic suite of tools — clinic doctor will literally diagnose your app and tell you "hey, your event loop is blocked here." It's like a doctor's appointment for your Node app, but without the waiting room.

The Takeaway

The event loop is not magic — it's a very fast bouncer working a very long shift. Respect it. Keep your synchronous, CPU-bound work off the main thread. Embrace async I/O for everything network and file-related. And when you truly need to crunch numbers, hand it to a worker thread.

Node.js can handle remarkable scale on a single core, but only if you work with the event loop instead of against it. Your future self — staring at a production incident at midnight — will thank you.


Want to go deeper? Check out the official Node.js event loop documentation, or run node --prof on your app to generate a V8 profiling log. The event loop has no secrets — it just rewards the people who bother to learn it.

Have a war story about accidentally blocking your event loop? Drop it in the comments — I promise it'll make someone else feel better about their own incident.