JavaScript Is Single-Threaded — And That’s Actually Fine
Before we even touch the event loop, we need to demolish a common misconception.
JavaScript runs on a single thread. This means it can only do one thing at a time. There is no parallel execution, no multiple CPU cores churning through your code simultaneously (at least, not in the main thread).
If you come from a language like Java or C++, this probably sounds like a huge limitation. And yet JavaScript powers real-time chat apps, live dashboards, video streaming platforms, and multiplayer games. How?
The answer is the event loop — a clever mechanism that makes a single thread feel like it’s doing many things at once by being incredibly smart about how it manages time.
┌─────────────────────────────────────────────────────────────────────┐│ THE MYTH vs. THE REALITY ││ ││ ❌ MYTH: "JavaScript is slow because it's single-threaded" ││ ││ ✅ REALITY: JavaScript's single thread + event loop lets it ││ handle thousands of concurrent operations by never ││ sitting idle — it delegates waiting to the environment ││ and moves on to the next thing immediately. │└─────────────────────────────────────────────────────────────────────┘The Problem the Event Loop Solves
Imagine you run a coffee shop — but you’re the only barista.
The bad approach (blocking): A customer orders a latte. You start making it, and you stand there staring at the espresso machine for 3 minutes waiting for it to brew. The entire line of customers waits. Nobody gets served. The shop is frozen.
The smart approach (non-blocking): A customer orders a latte. You start the espresso machine, then immediately turn to the next customer and take their order. When the machine beeps, you come back, finish the latte, and serve it. The shop keeps moving.
JavaScript uses the smart approach. When it encounters something slow — fetching data from a server, reading a file, waiting for a timer — it doesn’t stand there waiting. It hands the task off to the browser (or Node.js), says “let me know when you’re done,” and immediately moves on to the next piece of code.
The event loop is the mechanism that checks: “Hey, is anything done yet? Let me run whatever needs to run next.”
The Key Players — Meet the Cast
The event loop doesn’t work alone. It’s part of a larger system. Let’s meet everyone involved.
┌──────────────────────────────────────────────────────────────────────────────┐│ THE JAVASCRIPT RUNTIME ENVIRONMENT ││ ││ ┌──────────────────────────┐ ││ │ CALL STACK │ JavaScript's to-do list. ││ │ ┌────────────────────┐ │ Code executes here, one frame at a time. ││ │ │ getCurrentUser() │ │ ││ │ ├────────────────────┤ │ ││ │ │ loadDashboard() │ │ ││ │ ├────────────────────┤ │ ││ │ │ main() │ │ ││ │ └────────────────────┘ │ ││ └──────────────────────────┘ ││ ││ ┌──────────────────────────┐ ┌────────────────────────────────────────┐ ││ │ WEB APIs / Node APIs │ │ CALLBACK QUEUE │ ││ │ │ │ (also called "Task Queue" or │ ││ │ setTimeout │ │ "Macrotask Queue") │ ││ │ fetch / XHR │ │ │ ││ │ DOM events │ │ [ clickHandler, timerCallback, ... ] │ ││ │ File I/O (Node) │ └────────────────────────────────────────┘ ││ │ setInterval │ ││ └──────────────────────────┘ ┌────────────────────────────────────────┐ ││ │ MICROTASK QUEUE │ ││ ┌──────────────────────────┐ │ (Promises, queueMicrotask, │ ││ │ EVENT LOOP │ │ MutationObserver) │ ││ │ │ │ │ ││ │ "Is the stack empty? │ │ [ .then(), .catch(), await... ] │ ││ │ Run the next task." │ └────────────────────────────────────────┘ ││ └──────────────────────────┘ │└──────────────────────────────────────────────────────────────────────────────┘Let’s understand each player in depth.
The Call Stack — Where Code Actually Runs
The call stack is JavaScript’s execution context. Think of it as a stack of plates. When you call a function, a new plate (called a “frame”) goes on top. When the function returns, its plate is removed. JavaScript always executes whatever is on top of the stack.
function greet(name) { // This function is called last but returns first return `Hello, ${name}!`;}
function buildMessage(user) { // This calls greet, which adds a new frame on top const message = greet(user.name); return message;}
function showWelcome() { const user = { name: 'Alice' }; // This calls buildMessage, which adds a frame, which calls greet, which adds another const result = buildMessage(user); console.log(result); // 'Hello, Alice!'}
showWelcome();Here’s exactly what the call stack looks like as this code runs:
Step 1: showWelcome() is called┌──────────────┐│ showWelcome │ ← top of stack (currently executing)└──────────────┘
Step 2: showWelcome calls buildMessage()┌──────────────┐│ buildMessage │ ← top of stack (currently executing)├──────────────┤│ showWelcome │ ← waiting for buildMessage to return└──────────────┘
Step 3: buildMessage calls greet()┌──────────────┐│ greet │ ← top of stack (currently executing)├──────────────┤│ buildMessage │ ← waiting for greet to return├──────────────┤│ showWelcome │ ← waiting for buildMessage to return└──────────────┘
Step 4: greet() returns, its frame is removed┌──────────────┐│ buildMessage │ ← top of stack (gets greet's return value)├──────────────┤│ showWelcome │└──────────────┘
Step 5: buildMessage() returns, its frame is removed┌──────────────┐│ showWelcome │ ← top of stack (gets buildMessage's return value)└──────────────┘
Step 6: showWelcome() logs and returns, stack is empty(empty stack)What is a Stack Overflow? When functions call themselves recursively without ever stopping, the stack grows and grows until the browser throws a Maximum call stack size exceeded error. There’s literally no more room for new frames.
// ❌ This causes a stack overflowfunction infinity() { return infinity(); // calls itself forever, stack grows until crash}infinity(); // RangeError: Maximum call stack size exceeded
// ✅ This is fine — a proper base case stops the recursionfunction countdown(n) { if (n <= 0) { console.log('Done!'); return; // base case: stops the recursion } console.log(n); countdown(n - 1); // recursive call, but will eventually hit the base case}countdown(5); // 5, 4, 3, 2, 1, Done!The Web APIs — Where Slow Things Live
When JavaScript encounters something that takes time — a network request, a timer, a user click — it doesn’t handle it directly. It hands it off to the Web APIs (in browsers) or C++ APIs (in Node.js).
These APIs live outside the JavaScript engine. They’re provided by the browser or Node.js environment, and they can handle things in the background without blocking the call stack.
console.log('1 - Starting timer');
// setTimeout is handed off to the Web API// JavaScript doesn't wait here — it moves on IMMEDIATELYsetTimeout(function onTimer() { console.log('3 - Timer fired! (after ~1000ms)');}, 1000);
console.log('2 - Script continues running');
// Output:// 1 - Starting timer// 2 - Script continues running// (1 second passes)// 3 - Timer fired! (after ~1000ms)Here’s what actually happened:
1. JS engine: "I see setTimeout — I'll hand this to the Web API" → Call stack: [ ] (setTimeout itself is done in the JS world) → Web API: "I'm counting 1000ms in the background"
2. JS engine: "Call stack is empty, let me run the next line" → console.log('2 - Script continues running') executes
3. (1000ms passes in the background) → Web API: "Timer done! I'll put onTimer in the callback queue"
4. Event Loop: "Call stack is empty AND there's something in the callback queue" → Moves onTimer to the call stack
5. JS engine: "I'll run onTimer" → console.log('3 - Timer fired!') executesThe Callback Queue (Macrotask Queue) — The Waiting Room
When a Web API finishes its work (timer expired, network request complete, user clicked a button), it doesn’t immediately run the callback. It puts the callback in the callback queue (also called the task queue or macrotask queue).
The callback queue is a proper queue — first in, first out. Callbacks are processed in the order they were added.
// Let's add multiple timers and see the order they firesetTimeout(() => console.log('Timer 1: 0ms delay'), 0);setTimeout(() => console.log('Timer 2: 100ms delay'), 100);setTimeout(() => console.log('Timer 3: 50ms delay'), 50);console.log('Synchronous: runs first');
// Output:// Synchronous: runs first ← synchronous code runs before ANY timer// Timer 1: 0ms delay ← 0ms timer fires after sync code// Timer 3: 50ms delay ← 50ms timer fires next// Timer 2: 100ms delay ← 100ms timer fires lastThings that add callbacks to the macrotask queue:
setTimeoutsetIntervalsetImmediate(Node.js)- I/O callbacks (network, file system)
- DOM events (click, keydown, scroll, etc.)
MessageChannel
The Microtask Queue — The VIP Lane
Here’s where things get interesting — and where most developers trip up.
There are two queues, and they have very different priorities.
The microtask queue is a higher-priority queue that runs between tasks. After every task completes, and before the event loop picks up the next task, it drains the entire microtask queue first — running every microtask until the queue is empty.
Things that add callbacks to the microtask queue:
Promise.then(),Promise.catch(),Promise.finally()async/await(under the hood, these use promises)queueMicrotask(fn)(explicit API for adding microtasks)MutationObserver(watches for DOM changes)
console.log('1 - Synchronous start');
setTimeout(() => { console.log('5 - Macrotask: setTimeout');}, 0);
Promise.resolve() .then(() => console.log('3 - Microtask: Promise .then()')) .then(() => console.log('4 - Microtask: chained .then()'));
console.log('2 - Synchronous end');
// Output:// 1 - Synchronous start ← synchronous code runs first// 2 - Synchronous end ← still synchronous// 3 - Microtask: Promise .then() ← microtasks run before macrotasks// 4 - Microtask: chained .then() ← ALL microtasks drain before next macrotask// 5 - Macrotask: setTimeout ← macrotask runs lastThis is the most important rule in the event loop: After every task (including the initial script), drain ALL microtasks before picking up the next macrotask.
Let’s visualize this more carefully:
┌─────────────────────────────────────────────────────────────────────────┐│ EVENT LOOP TICK ORDER ││ ││ ┌──────────────────────────────────────────┐ ││ │ 1. Execute the current TASK │ ← runs one task at a time ││ │ (initial script, setTimeout cb, etc.) │ ││ └──────────────────────┬───────────────────┘ ││ │ ││ ▼ ││ ┌──────────────────────────────────────────┐ ││ │ 2. Drain MICROTASK QUEUE │ ← runs ALL microtasks ││ │ Run every queued microtask. │ ││ │ If a microtask adds more microtasks, │ ││ │ run those too — until queue is empty │ ││ └──────────────────────┬───────────────────┘ ││ │ ││ ▼ ││ ┌──────────────────────────────────────────┐ ││ │ 3. Render (browser only) │ ← update the screen ││ │ If needed, the browser paints the UI │ ││ └──────────────────────┬───────────────────┘ ││ │ ││ ▼ ││ ┌──────────────────────────────────────────┐ ││ │ 4. Pick next MACROTASK from queue │ ← go back to step 1 ││ │ (setTimeout, click event, etc.) │ ││ └──────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────────┘Putting It All Together — The Event Loop Algorithm
Now we can state the event loop’s algorithm precisely:
while (there is more work to do) { 1. Run the current script / task to completion (JavaScript NEVER pauses in the middle of a task)
2. Drain the microtask queue completely: while (microtask queue is not empty) { take the next microtask execute it if it adds more microtasks, add them to the queue }
3. If the browser needs to render, do that
4. Take the next task from the macrotask queue (if the queue is empty, wait until something arrives) Go back to step 1}Let’s trace through a complex example step by step:
console.log('START'); // Line A
setTimeout(() => console.log('setTimeout 1'), 0); // Line B
Promise.resolve() .then(() => { // Line C console.log('Promise 1'); setTimeout(() => console.log('setTimeout 2'), 0); // Line D (inside a microtask!) }) .then(() => console.log('Promise 2')); // Line E
setTimeout(() => console.log('setTimeout 3'), 0); // Line F
console.log('END'); // Line G
// What do you think the output is? Take a guess before scrolling down!Let's trace it:
TASK 1 (the script itself): Line A: console.log('START') → prints "START" Line B: setTimeout(cb1, 0) → sends cb1 to Web API; queues cb1 in macrotask queue after 0ms Line C: Promise.resolve().then(cb2) → cb2 added to MICROTASK queue Line E: .then(cb3) → cb3 will be added to microtask queue when cb2 resolves Line F: setTimeout(cb4, 0) → cb4 queued in macrotask queue Line G: console.log('END') → prints "END"
Script is done. Microtask queue: [cb2]
MICROTASK DRAIN: Run cb2: console.log('Promise 1') → prints "Promise 1" setTimeout(cb5, 0) → cb5 queued in macrotask queue cb2 returned → cb3 is now queued in microtask queue
Microtask queue still has: [cb3]
Run cb3: console.log('Promise 2') → prints "Promise 2"
Microtask queue is empty. ✓
MACROTASK QUEUE: [cb1, cb4, cb5]
TASK 2: Run cb1: console.log('setTimeout 1') → prints "setTimeout 1" No new microtasks.
TASK 3: Run cb4: console.log('setTimeout 3') → prints "setTimeout 3" No new microtasks.
TASK 4: Run cb5: console.log('setTimeout 2') → prints "setTimeout 2"
FINAL OUTPUT: START END Promise 1 Promise 2 setTimeout 1 setTimeout 3 setTimeout 2Notice that setTimeout 2 prints last even though it was scheduled from inside Promise 1. That’s because it was scheduled from a microtask, so it joins the macrotask queue after setTimeout 1 and setTimeout 3 were already there.
Synchronous vs. Asynchronous — The Foundation
Before diving deeper, let’s make sure the difference is crystal clear.
Synchronous code runs line by line. Each line must complete before the next one starts. The call stack is the only thing involved.
// Synchronous — runs top to bottom, in order, no exceptionsfunction calculateTotal(prices) { let total = 0; for (const price of prices) { total += price; // must finish before next iteration } return total; // must finish before caller continues}
const prices = [9.99, 24.99, 4.49];const total = calculateTotal(prices); // JS waits here for calculateTotal to returnconsole.log(total); // 39.47 — runs after calculateTotal completesAsynchronous code registers a callback and moves on. The callback runs later, when the async operation finishes.
// Asynchronous — registers callback, moves on immediatelyconsole.log('Before fetch');
// fetch() returns immediately — JS does NOT wait for the network responsefetch('https://api.example.com/price') .then((response) => response.json()) // runs when response arrives (maybe 200ms later) .then((data) => { console.log('Price from server:', data.price); // runs after JSON is parsed });
console.log('After fetch'); // runs BEFORE the fetch completes
// Output:// Before fetch// After fetch// Price from server: 99.99 ← arrives 200ms later// ❌ WRONG — data is undefined herelet data;fetch('/api/data').then((r) => r.json()).then((result) => {data = result; // This runs LATER, after the fetch completes});console.log(data); // undefined — fetch hasn't finished yet!// ✅ CORRECT — work with data inside the callbackfetch('/api/data').then((r) => r.json()).then((result) => {console.log(result); // data is available heredoSomethingWith(result); // do your work here});
Callbacks — The Original Async Pattern
The simplest way to handle asynchronous operations is with callbacks — functions you pass to another function to be called later.
// setTimeout takes a callback (the function to run after the delay)setTimeout(function () { console.log('This runs after 2 seconds');}, 2000);
// DOM events use callbacksdocument.getElementById('myButton').addEventListener('click', function (event) { console.log('Button was clicked!', event.target);});
// Node.js file reading uses a callbackconst fs = require('fs');fs.readFile('./menu.txt', 'utf8', function (error, content) { if (error) { console.error('Could not read file:', error); return; } console.log('File content:', content);});Callbacks work, but they get painful when you need to chain multiple async operations:
// ❌ Callback hell — each async operation depends on the previous one// This is real code people wrote in 2013. Don't be this person.getUserFromDatabase(userId, function (error, user) { if (error) { handleError(error); return; }
getUserPermissions(user.id, function (error, permissions) { if (error) { handleError(error); return; }
getProductsForUser(user.id, permissions, function (error, products) { if (error) { handleError(error); return; }
filterByCategory(products, 'electronics', function (error, filtered) { if (error) { handleError(error); return; }
console.log(filtered); // Finally! 4 levels deep. }); }); });});// This pyramid of doom becomes unmaintainable. Error handling is duplicated.// This is why Promises were invented.Promises — A Better Way to Handle Async
A Promise is an object that represents the eventual result of an asynchronous operation. It can be in one of three states:
┌─────────────────────────────────────────────────────────────────────────┐│ PROMISE STATES ││ ││ PENDING ──────► FULFILLED (operation succeeded, has a value) ││ │ ││ └───────► REJECTED (operation failed, has a reason/error) ││ ││ Once settled (fulfilled or rejected), a promise NEVER changes state. ││ A fulfilled promise stays fulfilled forever. ││ A rejected promise stays rejected forever. │└─────────────────────────────────────────────────────────────────────────┘// Creating a Promise manually (you'll do this less than you think;// most async functions return promises for you automatically)const myPromise = new Promise(function (resolve, reject) { // This function runs immediately (synchronously) // resolve(value) = operation succeeded with this value // reject(error) = operation failed with this error
const success = true; // pretend this is the result of some operation
if (success) { resolve('The operation worked! Here is your data.'); } else { reject(new Error('Something went wrong!')); }});
// Consuming a Promise with .then() and .catch()myPromise .then(function (value) { // Runs if the promise is FULFILLED console.log('Success:', value); }) .catch(function (error) { // Runs if the promise is REJECTED console.error('Error:', error.message); }) .finally(function () { // ALWAYS runs, whether fulfilled or rejected // Perfect for cleanup: hiding loading spinners, closing connections console.log('Done, regardless of outcome'); });The key insight: .then(), .catch(), and .finally() all return new promises, which means you can chain them:
// ✅ Promise chaining — same logic as callback hell, but readablefetch('https://api.example.com/products/123') .then((response) => { // Check if the HTTP response was ok (status 200-299) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); // parse JSON — also returns a promise! }) .then((product) => { console.log('Product name:', product.name); console.log('Price:', product.price); return fetch(`https://api.example.com/reviews/${product.id}`); // chain another request }) .then((response) => response.json()) .then((reviews) => { console.log('Reviews:', reviews); }) .catch((error) => { // ONE catch handles errors from ANY step in the chain console.error('Something went wrong:', error); });Creating a time-based Promise (a super useful pattern):
// A promisified version of setTimeout — useful for delays in async codefunction delay(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); // resolve (with no value) after ms milliseconds });}
// Usage:delay(2000).then(() => console.log('2 seconds have passed!'));
// Or with async/await (we'll cover this next):// await delay(2000);// console.log('2 seconds have passed!');Promise.all, Promise.race, and Friends — Running Promises Together
Sometimes you need to run multiple async operations and handle them together.
// Promise.all — run in PARALLEL, wait for ALL to complete// Rejects immediately if ANY promise rejects (fail-fast)const userPromise = fetch('/api/user/123').then((r) => r.json());const productPromise = fetch('/api/products').then((r) => r.json());const settingsPromise = fetch('/api/settings').then((r) => r.json());
Promise.all([userPromise, productPromise, settingsPromise]) .then(([user, products, settings]) => { // ALL THREE are available here — destructured in order console.log('User:', user.name); console.log('Products:', products.length); console.log('Theme:', settings.theme); }) .catch((error) => { // If ANY of the three fetches fails, we end up here console.error('At least one request failed:', error); });
// ─────────────────────────────────────────────────────────────────────
// Promise.allSettled — run in PARALLEL, wait for ALL, never rejects// Each result tells you whether it fulfilled or rejectedPromise.allSettled([userPromise, productPromise, settingsPromise]).then( (results) => { results.forEach((result, index) => { if (result.status === 'fulfilled') { console.log(`Request ${index} succeeded:`, result.value); } else { console.log(`Request ${index} failed:`, result.reason); } }); },);// Use this when you want ALL results even if some fail
// ─────────────────────────────────────────────────────────────────────
// Promise.race — resolves/rejects with WHICHEVER settles first// Great for timeouts!function fetchWithTimeout(url, timeoutMs) { const fetchPromise = fetch(url).then((r) => r.json());
const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs, ); });
// Whichever finishes first "wins" return Promise.race([fetchPromise, timeoutPromise]);}
fetchWithTimeout('/api/slow-endpoint', 3000) .then((data) => console.log('Got data:', data)) .catch((error) => console.error(error.message)); // 'Request timed out after 3000ms'
// ─────────────────────────────────────────────────────────────────────
// Promise.any — resolves with the FIRST to FULFILL (ignores rejections)// Only rejects if ALL promises rejectconst mirrors = [ fetch('https://mirror1.example.com/data'), fetch('https://mirror2.example.com/data'), fetch('https://mirror3.example.com/data'),];
Promise.any(mirrors) .then((response) => { console.log('Got response from the fastest mirror!'); return response.json(); }) .catch(() => { console.error('All mirrors failed!'); });async/await — Making Async Look Like Sync
async and await are syntax sugar built on top of Promises. They don’t introduce new async concepts — they just make Promise-based code much easier to read and write.
The rules:
asyncbefore a function makes it always return a Promiseawaitpauses execution of that async function until the Promise resolves (but doesn’t block the thread — other code can run)- You can only use
awaitinside anasyncfunction
// Same fetch chain as before — but with async/awaitasync function loadProductPage(productId) { try { // await pauses THIS function, but NOT the entire JavaScript engine const response = await fetch( `https://api.example.com/products/${productId}`, );
if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); }
const product = await response.json(); // pause again for JSON parsing
const reviewsResponse = await fetch( `https://api.example.com/reviews/${product.id}`, ); const reviews = await reviewsResponse.json();
// All data is available here, reading top-to-bottom like sync code console.log('Product:', product.name); console.log('Reviews:', reviews.length);
return { product, reviews }; // async functions always return a Promise } catch (error) { // try/catch catches errors from any awaited promise in this block console.error('Failed to load product page:', error); throw error; // re-throw so the caller knows it failed }}
// Calling an async function — it returns a PromiseloadProductPage('123') .then((data) => console.log('Loaded:', data)) .catch((err) => console.error('Outer error handler:', err));
// Or use await in another async function:async function main() { const data = await loadProductPage('123'); console.log('Loaded:', data);}Async/await is just syntactic sugar — here’s the proof:
// These two functions are IDENTICAL in behavior:
// Version 1: Promisesfunction fetchUser_promises(id) { return fetch(`/api/users/${id}`) .then((response) => response.json()) .then((user) => { console.log(user.name); return user; });}
// Version 2: async/await (does EXACTLY the same thing)async function fetchUser_async(id) { const response = await fetch(`/api/users/${id}`); const user = await response.json(); console.log(user.name); return user;}Running async operations in parallel with await:
// ❌ SLOW — these run sequentially, each waiting for the previousasync function slowVersion() { const user = await fetch('/api/user').then((r) => r.json()); // waits ~200ms const products = await fetch('/api/products').then((r) => r.json()); // then waits ~200ms const settings = await fetch('/api/settings').then((r) => r.json()); // then waits ~200ms // Total: ~600ms — they could be running simultaneously!}
// ✅ FAST — start all three, then wait for all to finishasync function fastVersion() { // Start ALL THREE fetch operations simultaneously (don't await yet) const userPromise = fetch('/api/user').then((r) => r.json()); const productPromise = fetch('/api/products').then((r) => r.json()); const settingsPromise = fetch('/api/settings').then((r) => r.json());
// NOW wait for all three to finish (they've been running in parallel) const [user, products, settings] = await Promise.all([ userPromise, productPromise, settingsPromise, ]); // Total: ~200ms — as fast as the slowest individual request}const productIds = ['101', '102', '103', '104', '105'];
// ❌ Sequential — fetches one at a time, very slowasync function fetchProductsSequential(ids) { const products = []; for (const id of ids) { const product = await fetchProduct(id); // waits for each before moving on products.push(product); } return products; // ~5 × 200ms = 1000ms}
// ✅ Parallel — fetches all at onceasync function fetchProductsParallel(ids) { const promises = ids.map((id) => fetchProduct(id)); // starts all fetches immediately return Promise.all(promises); // waits for all to complete // ~200ms (as long as the slowest single fetch)}What “Non-Blocking” Actually Means
This term gets thrown around constantly. Let’s nail down exactly what it means.
Blocking means the call stack is occupied and nothing else can run.
// ❌ BLOCKING — this locks up the entire browser tab for 3 seconds// During these 3 seconds: no clicks work, no animations play, the page is frozenfunction blockFor3Seconds() { const start = Date.now(); // Busy-wait: keep looping until 3 seconds have passed while (Date.now() - start < 3000) { // doing nothing, but keeping the call stack occupied } console.log('Done blocking!');}
console.log('Before block');blockFor3Seconds(); // EVERYTHING freezes for 3 seconds hereconsole.log('After block'); // only runs after 3 secondsNon-blocking means the call stack is free to do other work while waiting.
// ✅ NON-BLOCKING — the call stack is free during the 3-second wait// During these 3 seconds: clicks work, animations play, other code runs normallyfunction waitFor3Seconds() { return new Promise((resolve) => { setTimeout(resolve, 3000); // hand the waiting to the Web API // Call stack is free immediately — we return here });}
console.log('Before wait');waitFor3Seconds().then(() => { console.log('Done waiting!'); // runs after 3 seconds, via the event loop});console.log('After wait setup'); // runs IMMEDIATELY after waitFor3Seconds() returns
// Output:// Before wait// After wait setup ← immediate// (3 seconds pass)// Done waiting!setInterval — Repeating Timers
setInterval is like setTimeout but repeats.
let count = 0;
// Runs the callback every 1000ms (approximately)const intervalId = setInterval(function () { count++; console.log(`Tick #${count}`);
// ALWAYS store the interval ID so you can stop it later if (count >= 5) { clearInterval(intervalId); // stop the interval after 5 ticks console.log('Interval stopped!'); }}, 1000);
// Output (one line per second):// Tick #1// Tick #2// Tick #3// Tick #4// Tick #5// Interval stopped!// ❌ setInterval with slow callback can pile upsetInterval(async function () { await doSlowOperation(); // if this takes 3000ms, next interval fires before this finishes!}, 1000);
// ✅ Recursive setTimeout — next call only starts after current one completesasync function poll() { await doSlowOperation(); // wait for completion setTimeout(poll, 1000); // schedule next run AFTER current is done}
poll(); // start the pollingEvent Listeners and the Event Loop
User interactions (clicks, keypresses, scroll) go through the event loop just like timers.
const button = document.getElementById('submitButton');
// The click callback is registered in the Web API (DOM events system)// When the user clicks, the callback goes into the macrotask queuebutton.addEventListener('click', function handleClick(event) { console.log('Button clicked!'); event.preventDefault(); // prevent form submission
// All the event loop rules apply here — this is a macrotask Promise.resolve().then(() => { console.log('This microtask runs before the next click event'); });});
// Multiple listeners on the same element fire in registration orderbutton.addEventListener('click', function secondHandler() { console.log('Second handler — always runs after the first');});Removing event listeners to prevent memory leaks:
function setupCounter() { let count = 0; const button = document.getElementById('counterButton');
function handleClick() { count++; console.log(`Count: ${count}`);
if (count >= 10) { // Clean up when done — important to prevent memory leaks button.removeEventListener('click', handleClick); console.log('Counter disabled after 10 clicks'); } }
// Store reference to the named function so you can remove it later button.addEventListener('click', handleClick);}
setupCounter();The Event Loop in Node.js — Slightly Different
Node.js uses the same event loop concept but has more phases (powered by libuv, a C++ library). Node.js adds two extra “queues” you should know:
┌──────────────────────────────────────────────────────────────────────────┐│ NODE.JS EVENT LOOP PHASES ││ ││ ┌──────────────────────────────────────────────────────────────────┐ ││ │ 1. timers → setTimeout, setInterval callbacks │ ││ ├──────────────────────────────────────────────────────────────────┤ ││ │ 2. pending callbacks → I/O errors from previous loop iteration │ ││ ├──────────────────────────────────────────────────────────────────┤ ││ │ 3. idle, prepare → internal Node.js use │ ││ ├──────────────────────────────────────────────────────────────────┤ ││ │ 4. poll → retrieve new I/O events; run I/O callbacks │ ││ ├──────────────────────────────────────────────────────────────────┤ ││ │ 5. check → setImmediate callbacks │ ││ ├──────────────────────────────────────────────────────────────────┤ ││ │ 6. close callbacks → socket.on('close', ...) etc. │ ││ └──────────────────────────────────────────────────────────────────┘ ││ ││ Between EVERY phase: microtask queue is fully drained ││ (Promise callbacks, process.nextTick) │└──────────────────────────────────────────────────────────────────────────┘Node.js has two special functions:
// process.nextTick — runs BEFORE microtasks (even before Promise.then)// Think of it as "before the next anything"// Use sparingly — can starve the event loop if overused
process.nextTick(() => console.log('nextTick'));Promise.resolve().then(() => console.log('Promise'));setImmediate(() => console.log('setImmediate'));setTimeout(() => console.log('setTimeout'), 0);console.log('synchronous');
// Output:// synchronous ← current synchronous code// nextTick ← process.nextTick (before promises!)// Promise ← microtask queue// setTimeout ← macrotask (timer phase)// setImmediate ← macrotask (check phase, after I/O)Common Event Loop Gotchas
Gotcha 1: setTimeout(fn, 0) is NOT zero delay
console.log('Before');
setTimeout(() => { console.log('setTimeout'); // always after synchronous code, no matter what}, 0); // 0ms delay!
console.log('After');
// Output (ALWAYS):// Before// After// setTimeout ← even with 0ms delay, waits for sync code to finishGotcha 2: Promises in loops need care
const items = [1, 2, 3, 4, 5];
// ❌ forEach with async doesn't wait — forEach doesn't understand promisesasync function processItems_wrong() { items.forEach(async (item) => { await processItem(item); // forEach doesn't await these! }); console.log('Done?'); // runs BEFORE any items are processed!}
// ✅ Use for...of with await for sequential processingasync function processItems_sequential() { for (const item of items) { await processItem(item); // properly waits for each } console.log('Done!'); // runs after ALL items are processed}
// ✅ Use Promise.all for parallel processingasync function processItems_parallel() { await Promise.all(items.map((item) => processItem(item))); console.log('Done!'); // runs after ALL items are processed (in parallel)}Gotcha 3: The closure trap in setTimeout loops
// ❌ Classic bug — all callbacks share the SAME 'i' variablefor (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); // prints 5, 5, 5, 5, 5 — not 0, 1, 2, 3, 4! }, i * 1000);}// Why? var is function-scoped, not block-scoped.// By the time the callbacks run, the loop has finished and i = 5.
// ✅ Fix 1: Use let instead of var (block-scoped, each iteration gets its own i)for (let i = 0; i < 5; i++) { setTimeout(function () { console.log(i); // prints 0, 1, 2, 3, 4 correctly }, i * 1000);}
// ✅ Fix 2: Create a new scope with an IIFE (for environments without let)for (var i = 0; i < 5; i++) { (function (capturedI) { setTimeout(function () { console.log(capturedI); // capturedI is a new variable for each iteration }, capturedI * 1000); })(i);}Gotcha 4: Unhandled Promise rejections
// ❌ This rejection is silently swallowed — terrible for debuggingasync function riskyOperation() { throw new Error('Something went wrong!');}
riskyOperation(); // No .catch(), no try/catch — error is lost in older Node versions
// ✅ Always handle rejectionsriskyOperation().catch((err) => console.error('Caught:', err));
// ✅ Or with async/awaitasync function main() { try { await riskyOperation(); } catch (err) { console.error('Caught:', err); }}
// ✅ Global safety net (use as a last resort, not a substitute for proper handling)// In browser:window.addEventListener('unhandledrejection', (event) => { console.error('Unhandled promise rejection:', event.reason);});
// In Node.js:process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection:', reason); process.exit(1); // fail fast in production});Gotcha 5: Long microtask chains can starve the render
// ⚠️ This can prevent the browser from painting the UI// because microtasks drain completely before renderingfunction recursiveMicrotask(count) { if (count <= 0) return; Promise.resolve().then(() => { // Do some work... recursiveMicrotask(count - 1); // adds another microtask });}
// This runs 10,000 microtasks before the browser gets a chance to render// The user sees a frozen screen during this timerecursiveMicrotask(10000);
// ✅ Break up long work with setTimeout to let the browser breathefunction recursiveWithBreaths(count) { if (count <= 0) return; // Do some work... setTimeout(() => recursiveWithBreaths(count - 1), 0); // yields to the browser}Visualizing the Event Loop — A Complete Walkthrough
Let’s do one final, comprehensive trace of a real-world scenario:
async function loadDashboard() { console.log('[1] loadDashboard started');
const userPromise = fetch('/api/user'); // fires immediately const statsPromise = fetch('/api/stats'); // fires immediately
console.log('[2] Both fetches initiated');
// Await both — whichever finishes first doesn't matter const [userRes, statsRes] = await Promise.all([userPromise, statsPromise]);
console.log('[4] Both fetches complete');
const [user, stats] = await Promise.all([userRes.json(), statsRes.json()]);
console.log('[5] Data parsed:', user.name, stats.total);}
console.log('[0] Script starts');loadDashboard();console.log("[3] loadDashboard returned (it's async!)");
// Output order:// [0] Script starts// [1] loadDashboard started// [2] Both fetches initiated// [3] loadDashboard returned (it's async!) ← BEFORE the awaited data arrives!// (network requests complete in the background)// [4] Both fetches complete// [5] Data parsed: Alice 1234The key insight: [3] prints before [4] and [5] even though it appears after loadDashboard() in the code. This is because loadDashboard() is async — calling it returns a Promise immediately (when it hits the first await), and the code below it continues running.
Quick Reference — The Event Loop in 10 Rules
- JavaScript runs on one thread — one thing at a time in the engine.
- The call stack must be empty before async callbacks can run.
- Synchronous code ALWAYS runs before any async callback.
- Microtasks (Promises, queueMicrotask) have higher priority than macrotasks (setTimeout, setInterval, I/O).
- After every task, ALL microtasks drain before the next task runs.
- setTimeout(fn, 0) ≠ “run immediately” — it means “run after current task and all pending microtasks”.
- await pauses the current async function, NOT the thread. Other code can run while await is waiting.
- Long synchronous operations BLOCK the event loop. Use Web Workers or break work into chunks for CPU-heavy tasks.
- Always handle Promise rejections — unhandled ones are silent bugs.
- async/await is syntactic sugar for Promises. They don’t change the event loop — just the syntax
Practice Exercises — Test Your Understanding
Work through these before reading the answers. Predict the output order.
Exercise 1:
console.log('A');setTimeout(() => console.log('B'), 0);console.log('C');Promise.resolve().then(() => console.log('D'));console.log('E');// What is the output order? (Answer: A, C, E, D, B)Exercise 2:
Promise.resolve() .then(() => { console.log('micro 1'); setTimeout(() => console.log('macro 2'), 0); }) .then(() => console.log('micro 3'));
setTimeout(() => console.log('macro 1'), 0);// What is the output order? (Answer: micro 1, micro 3, macro 1, macro 2)Exercise 3:
async function alpha() { console.log('alpha start'); await Promise.resolve(); console.log('alpha end');}
async function beta() { console.log('beta start'); await Promise.resolve(); console.log('beta end');}
console.log('start');alpha();beta();console.log('end');// What is the output order?// (Answer: start, alpha start, beta start, end, alpha end, beta end)