The Node.js Event Loop

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:
- Synchronous code (call stack)
- Promises/async (microtask queue)
- 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)



