Skip to main content

Command Palette

Search for a command to run...

The Node.js Event Loop

Updated
17 min read
The Node.js Event Loop
H
CS undergrad | Tech enthusiast | Focusing on Web Dev • DSA • ML | Building skills for real-world impact

Node.js run single thread. But handle thousands requests. How? Event loop. Event loop = manager that decide what code run when. Without it, Node.js freeze on first slow task.

This about understanding event loop and how it make Node.js fast.


What The Event Loop Is

Event loop = forever-running manager. Check what task ready, run it, repeat.

Simple Definition

Event loop = system that manage code execution. When one task finish, event loop grab next task. One at time, but very fast.

Real Analogy

Coffee shop barista:

Barista job = event loop

1. Customer 1 order coffee
   → Add to task queue

2. Customer 2 order coffee
   → Add to task queue

3. Customer 1 order ready
   → Grab from queue, serve customer 1

4. Customer 3 order
   → Add to task queue

5. Customer 2 order ready
   → Grab from queue, serve customer 2

Barista work one task at time.
But move very fast. Seem like doing all at once.

Event loop = barista. Task queue = order list. Node.js = coffee shop.

Single Thread Problem

Node.js run on single thread. One piece code run at time.

Normal language (blocking):
  User 1 request → Server busy (10 seconds)
                 → User 2 wait
                 → User 3 wait
                 → User 100 wait

If use blocking code, other users stuck.

Node.js (event loop):
  User 1 request → Start task, move on
  User 2 request → Start task, move on
  User 3 request → Start task, move on
  User 100 request → Start task, move on
  
  Meanwhile...
  User 1 task done → handle response
  User 2 task done → handle response
  etc.

Event loop = manage all at once without blocking.

Why Event Loop Matter

Without event loop:

// Code hang forever on slow task
fs.readFileSync('huge-file.txt');  // Block 10 seconds
// Everything wait 10 seconds

With event loop:

// Code don't wait
fs.readFile('huge-file.txt', () => {
  // Run later when file ready
});
// Continue immediately

Single Thread Limitation

Node.js only one thread. One line code run at time.

Thread = What?

Thread = worker. One thread = one worker.

Normal server:

10 threads → 10 workers → handle 10 users at once
Each user have own thread

Node.js:

1 thread → 1 worker → handle 1,000 users at once
But very smart worker (event loop)

Why Single Thread?

Advantage:

  • Simple. No complicated multi-thread stuff.
  • No thread collision (same variable, two threads edit = crash).
  • Event loop handle many users.

Disadvantage:

  • Can't do CPU-heavy work (loop 1 billion times = freeze).
  • Heavy math block everything.

The Event Loop Solution

Event loop = cheat. Act like many threads but only one.

Event loop work:

Check call stack: anything running?
  Yes → Let it finish
  No → Check task queue

Task queue have jobs?
  Yes → Grab one, run it
  No → Wait

Repeat forever

User see many tasks happening. Actually one at time. But so fast, seem parallel.


Call Stack vs Task Queue

Two place where code live: call stack and task queue.

Call Stack

Call stack = where code execute right now.

function greet() {
  console.log('Hello');
}

function main() {
  greet();  // Call stack: main → greet
}

main();

Call stack during execution:

Step 1: main() call
  Stack: [main]

Step 2: main call greet()
  Stack: [main, greet]

Step 3: greet run, print "Hello"
  Stack: [main, greet]

Step 4: greet finish
  Stack: [main]

Step 5: main finish
  Stack: []

Call stack is LIFO (last in, first out). Last function added, first to finish.

Task Queue

Task queue = where async tasks wait. They not run yet.

setTimeout(() => {
  console.log('Later');
}, 1000);

console.log('Now');

Execution:

Step 1: Call setTimeout
  → Callback add to task queue (but not run yet)
  → Timer start (1 second)

Step 2: console.log('Now') run
  Print: "Now"

Step 3: 1 second pass
  → Callback in queue, ready

Step 4: Call stack empty
  → Event loop grab from task queue
  → Run callback

Step 5: Print "Later"

Call Stack vs Task Queue

CALL STACK              TASK QUEUE
═════════════════       ════════════════════
Where code run now      Where async wait

LIFO (stack)            FIFO (queue)

Run immediately         Run when stack empty

Normal function         Async callback
console.log             setTimeout
math operation          fs.readFile
                        db.query

Event Loop Execution Cycle

Event loop = forever loop. Check → run → check → run.

Step By Step

Event Loop Cycle:

1. Check call stack
   → Anything running?
      No → Go to step 2
      Yes → Wait for finish, go to step 2

2. Check task queue
   → Any callback waiting?
      Yes → Grab first callback
            Run it (add to call stack)
            Go to step 1
      No → Go to step 3

3. Check for more callbacks
   → Wait until something in queue
   → Go to step 1

Repeat forever (while Node.js alive)

Code Example: Event Loop in Action

console.log('1. Start');

setTimeout(() => {
  console.log('3. Timeout callback');
}, 0);  // Even 0ms, go to queue

console.log('2. End');

Output:

1. Start
2. End
3. Timeout callback

Why?

Step 1: console.log('1. Start') run
  Print: "1. Start"
  Call stack: [log]
  
Step 2: setTimeout call
  Callback go to task queue
  Timer (0ms) start immediately
  Call stack: empty

Step 3: console.log('2. End') run
  Print: "2. End"
  Call stack: empty

Step 4: Event loop check
  Call stack: empty
  Task queue: [callback waiting]
  
Step 5: Event loop grab callback from queue
  Run callback (print "3. Timeout callback")

Output:
1. Start
2. End
3. Timeout callback

Even setTimeout(..., 0) don't run immediately. Callback go to queue. Wait for stack empty.

Bigger Example

console.log('A');

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

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

console.log('D');

Output:

A
D
C
B

Why?

Execution:
console.log('A')        → Print "A"
setTimeout(...)         → Callback to task queue
Promise.then(...)       → Callback to microtask queue (different queue)
console.log('D')        → Print "D"

Call stack empty. Event loop check queues:

First: Microtask queue (Promise callbacks run first)
  Run callback → Print "C"

Then: Task queue (Timer callbacks run after)
  Run callback → Print "B"

Node.js have two queues actually:

  • Microtask queue = Promise, async/await (run first)
  • Task queue = setTimeout, setInterval (run second)

Not too deep. Just know: Promise callback run before setTimeout callback.


How Async Operations Work

Async operations use event loop. Start task, don't wait. Later callback fire.

File Read

const fs = require('fs');

console.log('1. Start');

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

console.log('2. Continue');

Output:

1. Start
2. Continue
3. File read done

Timeline:

Step 1: fs.readFile call
  → File reading start (background)
  → Callback add to task queue
  → But not run yet

Step 2: Continue immediately
  → Don't wait for file

Step 3: Call stack empty

Step 4: File reading done
  → Callback ready in queue

Step 5: Event loop run callback
  → Print "3. File read done"

Database Query

const db = require('./db');

console.log('1. Query start');

db.query('SELECT * FROM users', (err, result) => {
  console.log('3. Query done:', result);
});

console.log('2. Did not wait');

Same pattern:

1. Query start (print)
2. Did not wait (print)
3. Query done: [...] (print after query finish)

Query happen background. Event loop run callback when done.

Multiple Async Operations

const fs = require('fs').promises;

console.log('1. Start');

async function loadData() {
  const file1 = await fs.readFile('file1.txt');
  const file2 = await fs.readFile('file2.txt');
  
  console.log('3. Both files loaded');
}

loadData();

console.log('2. Continue');

Output:

1. Start
2. Continue
3. Both files loaded

Why "2" before "3"?

Step 1: loadData() call
  → First await (readFile)
  → Callback add to queue
  → Function pause (await)
  → Return immediately

Step 2: Continue (print)
  → Call stack empty

Step 3: File 1 ready
  → First await resolve
  → loadData continue
  → Second await (readFile)
  → Another callback add to queue
  → Function pause again

Step 4: File 2 ready
  → Second await resolve
  → Print "3. Both files loaded"

Each await pause function. Event loop continue other work. When callback ready, function resume.


Timers vs I/O Callbacks

Different type async operations. Different timing.

Timers: setTimeout, setInterval

setTimeout(() => {
  console.log('Timer done');
}, 1000);  // Wait 1000ms

How it work:

Event loop:

1. setTimeout call
   → Add callback to task queue
   → Timer start 1000ms

2. Event loop continue
   → Do other work

3. 1000ms pass
   → Timer ready
   → Callback can run

4. Call stack empty
   → Event loop grab callback
   → Run it

5. Print "Timer done"

Timers = time-based. Wait X milliseconds.

I/O Callbacks: File read, Database

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

How it work:

Event loop:

1. fs.readFile call
   → File reading start (OS handle)
   → Callback add to queue (but not ready yet)

2. Event loop continue
   → Do other work

3. OS finish reading file
   → Callback marked ready
   → (Might be 10ms, might be 1 second)

4. Call stack empty
   → Event loop grab callback
   → Run it

5. Print "File done"

I/O = depends on OS. Could be slow. Could be fast. Event loop not know when.

Difference

TIMERS                      I/O
══════════════════          ════════════════════
Known wait time             Unknown wait time
Wait X milliseconds         Wait until OS done

setTimeout(...)             fs.readFile
setInterval(...)            db.query
                            http.get

Useful for delays           Useful for reading
Useful for retry            Useful for querying

Both use event loop. Both don't block. But different reason when callback fire.


Event Loop and Scalability

Event loop = why Node.js fast for many users.

The Problem: Threads

Traditional server (many threads):

100 users → 100 threads
Each thread = memory
Each thread = overhead

Total memory = high

Creates 100 threads = slow. Handle 100 users = fine. Handle 1000 users = system crash (not enough memory).

The Solution: Event Loop

Node.js (one thread, event loop):

1000 users → 1 thread + event loop
Handle first user → task go to queue
Handle second user → task go to queue
Handle third user → task go to queue
...

Event loop manage all at once.
Total memory = low
Handle 1000 users = easy
Handle 10,000 users = still working

Event loop = memory efficient.

Why Event Loop Scale Better

User come:
  Server start async operation
  User task go to queue
  Server immediately free (handle next user)

Meanwhile:
  User 1 operation running (background)
  User 2 operation running (background)
  User 3 operation running (background)
  ...

When operation done:
  Callback fire
  Server send response to user

Same 1 thread. Same code. But handle many users.

Example: Real Server Load

const express = require('express');
const app = express();

app.get('/user/:id', async (req, res) => {
  // 1. Start database query
  const user = await db.getUser(req.params.id);
  
  // 2. Query happen background (not blocking)
  
  // 3. When ready, callback fire
  
  // 4. Send response
  res.json(user);
});

app.listen(3000);

Handle 1000 requests:

Request 1 → Start DB query (background)
Request 2 → Start DB query (background)
Request 3 → Start DB query (background)
...
Request 1000 → Start DB query (background)

All queries run parallel (in background).
Server only use 1 thread.
Memory = low.
Speed = fast.

When Request 1 query done → Send response
When Request 2 query done → Send response
etc.

Without event loop:

  • Request 1 query (5 seconds)
  • Request 2 wait
  • Request 3 wait
  • ... 1000 requests wait
  • Total time = 5000 seconds (impossible)

With event loop:

  • All 1000 queries at same time
  • Total time = ~5 seconds

That why Node.js famous for scaling.


Event Loop Problems (CPU-Heavy Code)

Event loop great for I/O. Bad for CPU-heavy code.

Problem: Long Running Code

app.get('/calculate', (req, res) => {
  // CPU heavy - loop 1 billion times
  let sum = 0;
  for (let i = 0; i < 1000000000; i++) {
    sum += i;
  }
  
  res.json({ sum });
});

What happen:

User 1 request
  → Start calculate loop
  → Block event loop (3 seconds)
  
During those 3 seconds:
  User 2 request (wait)
  User 3 request (wait)
  User 4 request (wait)
  
User 1 response send (3 seconds later)
  → Now User 2 can start
  → Now User 3 can start

CPU-heavy code = freeze event loop. Other users suffer.

Solution: Worker Threads

For heavy work, use worker threads (separate thread):

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

app.get('/calculate', (req, res) => {
  const worker = new Worker('./calculate.js');
  
  worker.on('message', (result) => {
    res.json({ sum: result });
  });
});

Worker thread do heavy work. Main thread (event loop) continue serve other users.

But mostly: avoid CPU-heavy code on server. Use Node.js for I/O, not math.


Event Loop in Real Code

Example 1: Simple Request

const express = require('express');
const app = express();

app.get('/hello', (req, res) => {
  console.log('Request come');
  res.json({ message: 'Hello' });
});

app.listen(3000);

Timeline:

User 1 request come
  → /hello route handler run
  → Print "Request come"
  → Send response
  → Handler finish (remove from stack)

User 2 request come
  → /hello route handler run
  → Print "Request come"
  → Send response
  → Handler finish

Event loop continue... always

Each request = separate handler. Event loop run one, then next.

Example 2: Database Request

const express = require('express');
const app = express();

app.get('/user/:id', async (req, res) => {
  console.log('1. Query start');
  
  const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
  
  console.log('2. Query done');
  res.json(user);
});

app.listen(3000);

Timeline:

User 1 request
  → Print "1. Query start"
  → await (pause here)
  → Query add to queue
  → Handler pause

User 2 request (meanwhile)
  → Print "1. Query start"
  → await (pause here)
  → Query add to queue
  → Handler pause

User 1 query done
  → Callback fire
  → Print "2. Query done"
  → Send response

User 2 query done
  → Callback fire
  → Print "2. Query done"
  → Send response

Both queries happen at same time. Event loop manage both.

Example 3: File Operations

const fs = require('fs').promises;

async function processFiles() {
  console.log('1. Start');
  
  const files = await Promise.all([
    fs.readFile('file1.txt'),
    fs.readFile('file2.txt'),
    fs.readFile('file3.txt')
  ]);
  
  console.log('2. All done');
}

processFiles();
console.log('3. Continue');

Output:

1. Start
3. Continue
2. All done

Timeline:

processFiles() call
  → Print "1. Start"
  → Promise.all start all 3 reads
  → await (pause function)

Print "3. Continue"
  → Main code continue

File reads happen background:
  OS read file 1
  OS read file 2
  OS read file 3

All 3 files done
  → Promise.all resolve
  → Function resume
  → Print "2. All done"

One thread. Three file reads at same time. Event loop magic.


Event Loop Visualization

Step-by-Step Flow

START
  │
  ├─→ Check Call Stack
  │   │
  │   ├─ Something running?
  │   │  Yes → Wait for finish
  │   │  No → Go to next
  │   │
  │
  ├─→ Check Microtask Queue (Promise)
  │   │
  │   ├─ Anything waiting?
  │   │  Yes → Run it, go back to stack check
  │   │  No → Go to next
  │   │
  │
  ├─→ Check Task Queue (setTimeout, I/O)
  │   │
  │   ├─ Anything waiting?
  │   │  Yes → Run it, go back to stack check
  │   │  No → Sleep until something arrive
  │   │
  │
  └─→ Loop again (forever)

Code Execution Order

// What print first?

console.log('A');                    // Call stack run

setTimeout(() => {
  console.log('B');                  // Task queue (timer)
}, 0);

Promise.resolve().then(() => {
  console.log('C');                  // Microtask queue (Promise)
});

console.log('D');                    // Call stack run

Order:

1. A, D (Call stack, synchronous code)
2. C    (Microtask queue, Promises)
3. B    (Task queue, Timers)

Output:
A
D
C
B

Event loop priority:

  1. Synchronous code (call stack)
  2. Promises/async (microtask queue)
  3. Timers/I/O (task queue)

Common Event Loop Mistakes

Mistake 1: Think Await Block Other Users

// ✗ Think this block other users
app.get('/user/:id', async (req, res) => {
  const user = await db.getUser(id);  // Blocking?
  res.json(user);
});

// ✓ Reality: don't block others
// await pause THIS function
// But event loop continue handle OTHER users

Each user have own context. Await pause one context, not all.

Mistake 2: Forget Event Loop on CPU Work

// ✗ Block event loop (bad for other users)
for (let i = 0; i < 1000000000; i++) {
  sum += i;  // CPU heavy
}

// ✓ Use worker thread or break into chunks
const chunk = async () => {
  sum += i;
  // resume later
};

CPU work can't use async. Must use worker threads or split work.

Mistake 3: Assume setTimeout(0) = Immediate

// ✗ Think callback run immediately
setTimeout(() => {
  console.log('Immediate?');
}, 0);

// ✓ Reality: callback wait in queue
// Go to task queue
// Run when stack empty
// NOT immediately

Even 0ms don't mean immediately. Go to queue. Might wait milliseconds.

Mistake 4: Not Understand Microtask Queue

// ✗ Think Promise and setTimeout same
console.log('A');

setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));

console.log('D');

// ✗ Expect: A, B, C, D
// ✓ Real: A, D, C, B

Promise use microtask queue (run first). setTimeout use task queue (run second).

Different queues = different timing.


Practice Assignment

1. Event loop order:

// Predict output order
console.log('1');

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

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

console.log('5');

// What order? Why?
// Test and verify

2. Async operations:

// Write function that:
// - Read 3 files
// - Do all 3 at same time (parallel)
// - Print results when all done
// Use Promise.all or async/await

3. Understand blocking:

// Create two endpoints:
// - One with CPU loop (block)
// - One without block

// Test: which handle user faster?
// Monitor: does one slow down other?

4. Event loop trace:

// Trace execution step by step

app.get('/test', async (req, res) => {
  console.log('A');
  
  const file = await fs.readFile('file.txt');
  
  console.log('B');
  res.json(file);
});

// When do A and B print?
// What if 3 users request at same time?

5. Fix scalability:

// Given slow endpoint
// Rewrite to use event loop better
// Should handle 1000 users, not 10

// Before: server crash with 1000 users
// After: server handle 1000 users easy

Quick Recap

  • Event loop = manager. Check what ready, run it, repeat.

  • Single thread = one code run at time. But event loop make seem parallel.

  • Call stack = where code run now. LIFO order.

  • Task queue = where async callback wait. FIFO order.

  • Event loop cycle: Check stack → Check queue → Run task → Repeat.

  • Async operations = start, don't wait. Callback fire later.

  • Microtask queue = Promise callbacks. Run before task queue.

  • Task queue = setTimeout, I/O callbacks. Run after microtask.

  • Timers = time-based. Wait X milliseconds.

  • I/O callbacks = depends on OS. When file/DB ready.

  • Scalability = event loop handle many users with 1 thread.

  • No threads needed = event loop do work of many threads.

  • Memory efficient = 1 thread vs 100 threads.

  • CPU-heavy code = freeze event loop. Use worker threads.

  • Await pause function = not block event loop. Other users continue.

  • setTimeout(0) != immediate = go to task queue. Wait until stack empty.

  • Promise run first = microtask queue before task queue.

  • Without event loop = Node.js can't scale. Dead project.

  • With event loop = 1000 users, 1 thread. That magic.

Event loop = why Node.js exist. Without it, just another server language.


If you enjoyed this article, check out my other blogs on this profile. Connect with me: LinkedIn | GitHub | X (Twitter)