0x55aa
Back to Blog

🔄 The Node.js Event Loop: The Waiter Who Never Sleeps

|
5 min read

🔄 The Node.js Event Loop: The Waiter Who Never Sleeps

Imagine a restaurant with one waiter. Not a lazy one — a hyper-efficient one who takes your order, immediately moves to the next table, and trusts the kitchen to call him when your food is ready. He's not standing at the stove watching your pasta boil. He's taking five more orders while that's happening.

That waiter is Node.js. The kitchen is your I/O operations. And the magic making all of it work without collapsing into chaos? The event loop.

Why Single-Threaded Doesn't Mean Slow

Most developers hear "single-threaded" and think bottleneck. After all, traditional servers like Apache spin up a new thread for every incoming request. Thousands of users? Thousands of threads. Lots of RAM. Lots of context switching. Lots of overhead.

Node.js flips this on its head. It uses one thread but almost never wastes time waiting. When Node hits something slow — a database query, a file read, a network call — it hands that work off to the operating system or a thread pool, registers a callback, and immediately moves on to handle the next request.

That's not a quirk. That's the whole architectural bet.

The Event Loop, Explained Without a CS Degree

The event loop is a continuous cycle that roughly looks like this:

  1. timers — Run any setTimeout / setInterval callbacks whose time has come
  2. pending callbacks — Handle I/O errors deferred from the previous cycle
  3. idle / prepare — Internal Node.js housekeeping
  4. poll — Fetch new I/O events; execute their callbacks (this is where most of your code lives)
  5. check — Run setImmediate callbacks
  6. close callbacks — Handle things like socket.on('close', ...)

Then it loops back. Forever. Until your process exits.

The key phase is poll. This is where Node blocks (briefly) waiting for I/O, then drains the callback queue. Most of your Express route handlers, database responses, and file reads land here.

Here's a classic gotcha that trips people up:

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
console.log('synchronous');

Output:

synchronous
timeout
immediate

console.log('synchronous') runs first — synchronous code always runs before the loop starts processing callbacks. Then setTimeout(..., 0) fires from the timers phase. Then setImmediate from the check phase. Knowing the phase order turns mysterious bugs into obvious ones.

The One Rule You Must Never Break

Here's the cardinal sin of Node.js development: blocking the event loop.

If you do something synchronous that takes a long time — parsing a giant JSON blob, running a complex regex, doing CPU-heavy math — every other request is frozen. Your waiter is stuck in the kitchen hand-delivering food instead of taking new orders. The whole restaurant grinds to a halt.

This is perfectly fine:

app.get('/users', async (req, res) => {
  // Async — Node hands this off and moves on
  const users = await db.query('SELECT * FROM users');
  res.json(users);
});

This will ruin your day:

app.get('/crunch', (req, res) => {
  // Synchronous CPU work — blocks EVERYTHING while this runs
  let result = 0;
  for (let i = 0; i < 10_000_000_000; i++) {
    result += i;
  }
  res.json({ result });
});

While that loop runs, no other request gets processed. Not a health check. Not a logout. Nothing. If you have CPU-intensive work, offload it to a Worker Thread or a separate service. The event loop is for coordination, not computation.

process.nextTick and the Microtask Queue (The Express Lane)

There's actually a queue that runs between every phase of the event loop: the microtask queue. Resolved Promises and process.nextTick callbacks land here, and they drain completely before the loop moves to the next phase.

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
setTimeout(() => console.log('timeout'), 0);

// Output:
// nextTick
// promise
// timeout

process.nextTick beats resolved Promises, which both beat setTimeout. Use process.nextTick sparingly — if you accidentally create a recursive nextTick loop, you'll starve the entire event loop and nothing else will ever run. It's the express lane, not a free pass to cut every queue.

Practical Takeaways

Profile before you optimize. Use --prof or the built-in performance API to find what's actually slow before assuming you have an event loop problem.

Use async/await for everything I/O. Don't use the synchronous versions of Node's file system APIs (fs.readFileSync) in a server context. There's almost never a good reason.

Move CPU work off the main thread. The worker_threads module exists for exactly this. Spawn a worker, do the math, post back the result. Your event loop stays clear.

Watch your Promise chains. Long .then() chains that do synchronous work between async calls can still starve the loop. Keep callbacks lean.

The Bottom Line

The event loop isn't magic — it's a disciplined scheduling system that keeps Node fast by never wasting time waiting. One thread, zero idle time, and thousands of concurrent connections handled gracefully. The rules are simple: don't block, keep callbacks short, and let async do its job.

Once you internalize the loop phases, bugs that used to seem random start making complete sense. And you stop accidentally building a restaurant where one complicated order backs up the entire dining room.

Now go forth and let your waiter run.


What's your most memorable event loop bug story? Drop it in the comments — I promise you're not the only one who's accidentally readFileSync-ed in production.

Thanks for reading!

Back to all posts