Understanding the Node.js Event Loop

Deep dive into the Node.js event loop, how it handles async operations, and how to write code that works efficiently with it.

The event loop is what allows Node.js to perform non-blocking I/O operations despite JavaScript being single-threaded.

How It Works

The event loop continuously checks for pending callbacks and executes them. It has multiple phases, each handling different types of callbacks.

Event Loop Phases

  1. **Timers**: Executes callbacks from setTimeout and setInterval
  2. **Pending callbacks**: Executes I/O callbacks deferred to the next loop
  3. **Idle, prepare**: Used internally
  4. **Poll**: Retrieves new I/O events and executes callbacks
  5. **Check**: Executes setImmediate callbacks
  6. **Close callbacks**: Handles close events

Microtasks and Macrotasks

Microtasks (Promise callbacks, process.nextTick) run between event loop phases. Macrotasks (setTimeout, setInterval) run in their respective phases.

Avoiding Event Loop Blocking

Long-running synchronous operations block the event loop. Use worker threads or break up work into chunks.

Code Examples

Event Loop Order

event-loop-order.js
console.log('1. Script start');

setTimeout(() => {
  console.log('4. setTimeout');
}, 0);

setImmediate(() => {
  console.log('5. setImmediate');
});

Promise.resolve().then(() => {
  console.log('3. Promise');
});

process.nextTick(() => {
  console.log('2. nextTick');
});

console.log('1b. Script end');

// Output order:
// 1. Script start
// 1b. Script end
// 2. nextTick
// 3. Promise
// 4. setTimeout (may vary with setImmediate)
// 5. setImmediate

Avoiding Blocking

non-blocking.js
// BAD: Blocks event loop
function calculateSync(n) {
  let result = 0;
  for (let i = 0; i < n; i++) {
    result += Math.sqrt(i);
  }
  return result;
}

// GOOD: Break into chunks
async function calculateAsync(n, chunkSize = 10000) {
  let result = 0;
  for (let i = 0; i < n; i += chunkSize) {
    const end = Math.min(i + chunkSize, n);
    for (let j = i; j < end; j++) {
      result += Math.sqrt(j);
    }
    // Yield to event loop
    await new Promise(resolve => setImmediate(resolve));
  }
  return result;
}

Worker Threads

worker.js
const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  // Main thread
  const worker = new Worker(__filename);

  worker.on('message', result => {
    console.log('Result from worker:', result);
  });

  worker.postMessage({ number: 1000000 });
} else {
  // Worker thread
  parentPort.on('message', ({ number }) => {
    let result = 0;
    for (let i = 0; i < number; i++) {
      result += Math.sqrt(i);
    }
    parentPort.postMessage(result);
  });
}

Frequently Asked Questions

What's the difference between setImmediate and setTimeout(fn, 0)?

setImmediate executes after the poll phase, while setTimeout(fn, 0) executes in the timers phase. In most cases, setImmediate will run first when called from within an I/O cycle.

Why does process.nextTick run before Promise callbacks?

process.nextTick is not part of the event loop - it runs immediately after the current operation. Promise microtasks run after nextTick callbacks but before the next event loop phase.

How can I detect if the event loop is being blocked?

Use the 'blocked-at' npm package or monitor event loop lag with setImmediate. If there's significant delay between scheduling and execution, your loop is blocked.

Need Node.js Help?

Slashdev.io builds production-ready Node.js applications for businesses of all sizes.

Get in Touch