Module 2: The Event Loop & Async Basics
Synchronous vs Asynchronous
JavaScript runs one thing at a time, in order — this is synchronous execution. But many tasks (fetching data from a server, reading a file, waiting for a timer) take time to finish. Asynchronous code lets those slow tasks happen in the background without freezing the rest of the page while they wait.
console.log("Start");
setTimeout(() => console.log("Delayed"), 1000);
console.log("End");
// Output order: "Start", "End", then "Delayed" (after ~1 second)
// The page doesn't freeze for that 1 second — other code keeps running
Call Stack & Web APIs
The call stack is where JavaScript keeps track of what function is currently running. When a function is called, it's pushed onto the stack; when it returns, it's popped off.
Slow operations like setTimeout, fetch, and DOM events aren't handled by
the JavaScript engine itself — they're handed off to Web APIs provided by the
browser, which run them separately and notify JavaScript once they're done.
Call Stack (JS engine) ←→ Web APIs (browser)
↓ hands off timers/fetch/events
↓ browser runs them in the background
↓ browser puts the callback in a QUEUE when done
↓ Event Loop moves it back to the Call Stack when the stack is empty
Microtask & Macrotask Queues
Finished async work doesn't jump straight back onto the call stack — it waits in a queue. There are two, with different priority:
- Microtask queue — Promise callbacks (
.then,async/await). Always processed FIRST, and fully drained before anything else runs. - Macrotask (task) queue —
setTimeout,setInterval, DOM events, I/O. Processed one at a time, after the microtask queue is empty.
console.log("1");
setTimeout(() => console.log("2 (macrotask)"), 0);
Promise.resolve().then(() => console.log("3 (microtask)"));
console.log("4");
// Output: 1, 4, 3, 2
// Microtasks (Promises) always run before the next macrotask (setTimeout),
// even when the setTimeout delay is 0
The Event Loop
The event loop is the mechanism that ties this all together — it continuously checks: "Is the call stack empty? If so, take the next task from the microtask queue (drain it fully), then take ONE task from the macrotask queue, then repeat."
┌───────────────────────┐
│ Call Stack │ ← runs your code, one thing at a time
└───────────┬─────────────┘
│ empty? pull next task
┌───────────▼─────────────┐
│ 1. Drain ALL microtasks │ ← Promise .then callbacks
│ 2. Run ONE macrotask │ ← one setTimeout/event callback
│ 3. Repeat │
└───────────────────────────────┘
This design is why a slow synchronous loop (a giant for loop, say) will freeze the
entire page — nothing in either queue can run until the call stack is empty again.
Callbacks
The original way to handle async code — pass a function to be called once a task finishes:
function loadData(callback) {
setTimeout(() => {
callback("Data loaded!");
}, 1000);
}
loadData((result) => {
console.log(result);
});
Nesting many callbacks (each waiting on the previous) creates deeply indented, hard-to-read code often called "callback hell" — the problem Promises (next chapter) were designed to solve.