Understanding the Event Loop, Web APIs, and Concurrency without losing your sanity
Introduction
You’ve probably heard this before:
“JavaScript is single-threaded.”
That’s true… but also not the full picture.
Yes, JavaScript runs one piece of code at a time in a single call stack. But modern JS can handle timers, I/O, DOM events, async/await, and even workers without freezing the browser. How is that possible if it’s “single-threaded”?
The magic lies in the event loop, Web APIs, and background threads managed by the environment (browser or Node.js).
In this guide, we’ll break it down step by step:
- Why JS is considered single-threaded.
- How the event loop schedules work.
- The role of Web APIs and Node’s libuv thread pool.
- Microtasks vs macrotasks (Promises vs
setTimeout
). - Where true multithreading comes in (Web Workers).
- Real-world scenarios and gotchas.
1) Single-Threaded at Its Core
JavaScript engines (like V8) execute code on a single main thread.
- Only one call stack.
- Code executes line by line.
- No two JS lines run literally at the same time.
Example:
console.log("A");
console.log("B");
Output is always:
A
B
👉 That’s what “single-threaded” means: one thread, one stack, one execution flow.
2) Then Why Doesn’t setTimeout
Block?
If JS is single-threaded, why doesn’t this freeze for 2 seconds?
console.log("Start");
setTimeout(() => console.log("After 2s"), 2000);
console.log("End");
Output:
Start
End
After 2s
👉 The trick: JS delegates async tasks to the environment (browser/Node).
- The JS thread doesn’t wait.
- The environment (Web API or libuv in Node) sets the timer in another thread.
- When ready, the callback is queued back into JS’s event loop.
3) Event Loop in a Nutshell
Think of JS runtime as:
- Call stack (runs one function at a time).
- Web APIs / Node APIs (timers, I/O, HTTP, DOM events).
- Callback queues (macrotask + microtask queues).
- Event loop (traffic cop that moves tasks from queues to stack).
Visual flow:
Code → Call Stack → (Async call?) → Web APIs → Task Queue → Event Loop → Back to Call Stack
4) Microtasks vs Macrotasks
Not all tasks are equal.
Macrotasks
- Scheduled for “later.”
- Examples:
setTimeout
,setInterval
,setImmediate
(Node).
Microtasks
- Run immediately after current stack, before the next macrotask.
- Examples:
Promise.then
,queueMicrotask
,process.nextTick
(Node).
Example:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Output:
1
4
3
2
👉 Microtasks (Promise.then
) always run before macrotasks (setTimeout
).
5) Real Concurrency: Background Threads
Even though JS itself is single-threaded, the environment uses threads:
- Browser Web APIs handle timers, DOM events, fetch requests, etc.
- Node.js uses libuv with a thread pool for I/O (file reads, DNS lookups, crypto).
So while your JS thread is busy, the heavy lifting happens elsewhere, then results are queued back.
Example (Node.js):
const fs = require("fs");
console.log("Read start");
fs.readFile("big.txt", "utf8", (err, data) => {
console.log("Read complete");
});
console.log("Doing other work");
Output:
Read start
Doing other work
Read complete
👉 The file read happens in libuv’s thread pool. JS never blocks.
6) True Multithreading: Web Workers
For CPU-heavy tasks (like image processing, machine learning), async alone isn’t enough — you need parallelism.
Enter Web Workers (browser) and Worker Threads (Node).
// main.js
const worker = new Worker("worker.js");
worker.postMessage(1000000);
worker.onmessage = e => console.log("Result:", e.data);
// worker.js
onmessage = e => {
let sum = 0;
for (let i = 0; i < e.data; i++) sum += i;
postMessage(sum);
};
Workers run in separate threads, not blocking the main thread.
👉 Caveat: Workers don’t share memory directly (except with SharedArrayBuffer). They communicate by messaging.
7) Why “Single Threaded — But Not Really”?
- Single-threaded JS engine: One call stack, no parallel execution of JS code.
- Multi-threaded environment: Browser/Node handles async tasks in background threads.
- Parallelism available via Workers: True multithreading, but isolated contexts.
So JS itself is single-threaded, but the runtime gives it concurrency superpowers.
8) Real-World Gotchas
8.1 Long-Running Loop Freezes UI
while (true) {
// ❌ Blocks forever
}
👉 JS thread is blocked. Async tasks (timers, events) can’t run.
Fix: Break work into chunks (e.g., setTimeout
, requestIdleCallback
, Workers).
8.2 Misunderstanding setTimeout
setTimeout(() => console.log("done"), 0);
console.log("end");
Output is always:
end
done
👉 Even 0ms
delay means “queue after current stack,” not immediate.
8.3 Async/Await Still Uses Event Loop
async function demo() {
console.log("A");
await null;
console.log("B");
}
demo();
console.log("C");
Output:
A
C
B
👉 await
yields to the event loop. The continuation runs in a microtask.
9) Quick Reference Table

Conclusion
JavaScript is single-threaded at its core — only one call stack, one thread executing code.
But thanks to the event loop, Web APIs, libuv, and workers, JS achieves concurrency and parallelism without breaking its single-threaded model.
So the next time someone says “JS is single-threaded,” you can reply:
👉 “Yes, but it runs in a multi-threaded world. The engine is single-threaded, but the runtime gives it background workers and an event loop that make it feel concurrent.”
Pro tip: If your UI freezes, you’re blocking the JS thread. If your async code runs “too late,” it’s waiting in the event loop. Knowing this distinction is the key to writing smooth, bug-free apps.
Call to Action
Have you ever been bitten by a blocking loop or confused by microtask vs macrotask order?
💬 Share your story in the comments.
🔖 Bookmark this as your event loop cheat sheet.
👩💻 Send to a teammate who still thinks setTimeout(fn, 0)
is instant.
Leave a Reply