Skip to main content

Command Palette

Search for a command to run...

The Node.js Event Loop Explained

Published
7 min read
The Node.js Event Loop Explained

The event loop is the heart of Node.js. It's what makes Node.js single-threaded yet capable of handling thousands of concurrent requests. Understanding it is crucial to writing efficient Node.js applications.

Why Node.js Needs an Event Loop

Node.js runs on a single thread. There's one thread executing your JavaScript code. But a single thread would be useless if it blocked on every slow operation.

The event loop is the solution. It lets you execute code non-blocking. While waiting for operations to complete, the event loop checks what's ready and executes callbacks.

The Basic Concept

Imagine a task manager:

  1. You give the manager tasks

  2. The manager executes tasks one by one

  3. For tasks that take time (like filing documents), the manager delegates to workers

  4. Once workers finish, the manager executes the callback

  5. The manager never sits idle waiting

This is the event loop.

How the Event Loop Works

When you run Node.js:

console.log('Start');

setTimeout(() => {
  console.log('Timeout');
}, 100);

console.log('End');

Output:

Start
End
Timeout

Here's what happens behind the scenes:

1. Main JavaScript code executes
   - Logs "Start"
   - setTimeout schedules callback (doesn't execute now)
   - Logs "End"
   - Main code complete

2. Event loop checks: Is anything ready?
   - 100ms haven't passed, nothing ready
   
3. 100ms passes

4. Event loop checks: Is anything ready?
   - Timeout callback is ready! Execute it
   - Logs "Timeout"

5. No more callbacks, event loop waits

The event loop is constantly checking: "Is there anything I need to do?"

Call Stack and Callback Queue

The event loop manages two things:

Call Stack: Where your code executes

Callback Queue: Where callbacks wait to execute

Your Code
   |
   v
Call Stack (currently executing)
   |
   v (if done)
Event Loop (checks: anything in queue?)
   |
   v
Callback Queue (waiting callbacks)

When the call stack is empty, the event loop picks a callback from the queue and executes it.

Visualizing the Event Loop

Here's a more detailed flow:

console.log('1');

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

console.log('3');

Timeline:

Time 0ms:
- Call Stack: [console.log('1')]
- Execute: Logs '1'
- Call Stack is empty

Time 0ms:
- Call Stack: [setTimeout callback scheduled]
- Callback added to queue
- Call Stack is empty

Time 0ms:
- Call Stack: [console.log('3')]
- Execute: Logs '3'
- Call Stack is empty

Time 0ms (main code done):
- Event Loop checks queue
- Callback queue has the setTimeout callback
- Move callback to call stack
- Execute: Logs '2'
- Call Stack is empty

Done

Output:

1
3
2

Even though setTimeout had 0ms delay, it doesn't execute immediately. It's always after the main code finishes.

Phases of the Event Loop

The event loop has several phases. Each phase handles different types of operations:

┌───────────────────────────┐
│      Event Loop Phases    │
├───────────────────────────┤
│   1. Timers               │ (setTimeout, setInterval)
│   2. Pending Callbacks    │ (OS operations)
│   3. Idle, Prepare        │ (Internal)
│   4. Poll                 │ (I/O operations)
│   5. Check                │ (setImmediate)
│   6. Close Callbacks      │ (Socket close)
└───────────────────────────┘

The event loop cycles through these phases repeatedly.

File I/O and the Event Loop

File operations demonstrate the event loop perfectly:

import fs from 'fs';

console.log('Start');

fs.readFile('file.txt', (err, data) => {
  console.log('File read');
});

console.log('End');

Output:

Start
End
File read

Timeline:

1. Main code starts
2. Logs "Start"
3. fs.readFile called - work delegated to file system worker
4. Logs "End"
5. Main code done

6. Event loop checks: Is file read done?
7. No, not yet

8. ...time passes...

9. Event loop checks: Is file read done?
10. Yes! Add callback to queue

11. Event loop executes callback from queue
12. Logs "File read"

Your code never waits. The file is read in the background while your code continues.

setTimeout vs setImmediate

Both defer execution, but they're in different phases:

setImmediate(() => console.log('Immediate'));
setTimeout(() => console.log('Timeout'), 0);

Output (usually):

Timeout
Immediate

setTimeout executes in the Timers phase, setImmediate in the Check phase. The event loop visits Timers first.

Microtasks vs Macrotasks

There's a subtle complexity: microtasks and macrotasks.

Microtasks (execute immediately after each operation):

  • Promises (.then, .catch, .finally)

  • process.nextTick()

Macrotasks (queued in phases):

  • setTimeout

  • setInterval

  • setImmediate

  • I/O operations

Here's the key: After each macrotask, the event loop executes ALL microtasks before moving to the next macrotask.

console.log('Start');

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

Promise.resolve()
  .then(() => console.log('Promise 1'))
  .then(() => console.log('Promise 2'));

console.log('End');

Output:

Start
End
Promise 1
Promise 2
Timeout

Timeline:

1. Main code: Logs "Start"
2. Main code: setTimeout scheduled (to Macrotask queue)
3. Main code: Promises created and scheduled (to Microtask queue)
4. Main code: Logs "End"
5. Main code done

6. Event loop checks: Any microtasks?
7. Yes! Execute Promise .then
8. Logs "Promise 1"

9. Microtask queue still has one
10. Execute it
11. Logs "Promise 2"

12. No more microtasks
13. Event loop checks: Any macrotasks?
14. Yes! setTimeout callback
15. Execute it
16. Logs "Timeout"

Promises always execute before setTimeout, even with 0ms delay!

Practical Example

Here's a realistic scenario:

import fs from 'fs/promises';

console.log('1: Start');

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

fs.readFile('file.txt', 'utf8')
  .then(data => {
    console.log('3: File read (Promise)');
  });

Promise.resolve()
  .then(() => console.log('4: Promise'));

console.log('5: End');

Output:

1: Start
5: End
4: Promise
3: File read (Promise)
2: setTimeout

Explanation:

  1. Synchronous code runs first (1, 5)

  2. Microtasks run (4)

  3. File read completes and resolves (3) - still a microtask

  4. Macrotasks run (2)

Common Mistakes

Mistake 1: Assuming setTimeout runs immediately

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

console.log('runs first');

Even with 0ms, setTimeout callback doesn't run immediately. It waits until the main code finishes.

Mistake 2: Long-running operations block the event loop

// Bad - blocks event loop for 5 seconds
function expensive() {
  for (let i = 0; i < 1_000_000_000; i++) {
    Math.sqrt(i);
  }
}

setTimeout(() => {
  expensive(); // Blocks entire event loop
}, 100);

// While expensive() runs, no other callbacks execute

Mistake 3: Thinking setImmediate is faster

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

setImmediate doesn't necessarily run first. It depends on whether the current phase is the Check phase.

Debugging the Event Loop

You can visualize event loop behavior:

const start = Date.now();

setTimeout(() => {
  console.log(`Timer: ${Date.now() - start}ms`);
}, 100);

Promise.resolve()
  .then(() => console.log(`Promise 1: ${Date.now() - start}ms`))
  .then(() => console.log(`Promise 2: ${Date.now() - start}ms`));

console.log(`Main: ${Date.now() - start}ms`);

Output:

Main: 0ms
Promise 1: 0ms
Promise 2: 0ms
Timer: 102ms

The promises run immediately (microtasks), the timer waits for its delay.

Key Takeaways

  • The event loop continuously checks what work is ready to do

  • Your JavaScript code runs on the main thread, blocking the event loop

  • I/O operations run on background workers, not blocking the event loop

  • Callbacks wait in queues until the event loop picks them up

  • Microtasks (Promises) always run before macrotasks (timers, I/O)

  • Even setTimeout(..., 0) doesn't run immediately

  • Long-running code blocks the entire event loop

  • Understanding the event loop is key to writing efficient Node.js

The event loop isn't magic. It's a simple idea: check what's ready, execute it, repeat. Master this concept, and you'll write Node.js code that scales and performs well.