π JavaScript Event Loop Explained
JavaScript runs on one thread, yet it juggles timers, clicks, and network responses without missing a beat. The event loop is the quiet coordinator that makes this possible. Once you can trace it, async code stops feeling like magic and starts feeling like rules.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Name the four pieces of the runtime β call stack, Web APIs, task queue, and microtask queue β and describe each one's job
- Explain how the event loop moves callbacks from the queues onto the call stack
- Distinguish macrotasks (e.g.
setTimeout) from microtasks (e.g. Promise callbacks) and their priority - Predict the exact output order of code that mixes synchronous statements, timers, and Promises
- Apply techniques to avoid blocking the main thread
Estimated Time: 30β40 minutes β’ Difficulty: Intermediate
Hands-on: Predict the output of a mixed sync/timeout/Promise program, then verify it.
In This Lesson
The Runtime at a Glance
In the previous lesson you saw that async callbacks run "later." The event loop is how "later" is decided. To understand it, picture the four parts of the JavaScript runtime working together.
π³ A kitchen analogy. One chef (the call stack) can only cook one dish at a time. Assistants and appliances (the Web APIs) handle slow tasks β a timer, an oven β in the background. Finished tickets pile up on a rail (the queues). A manager (the event loop) hands the chef the next ticket, but only when the chef's hands are empty.
- Call stack β where your code actually runs, one frame at a time.
- Web APIs β browser features (timers, network, DOM events) that do slow work off the stack.
- Task queue (macrotasks) β finished callbacks from timers, events, etc., waiting their turn.
- Microtask queue β higher-priority callbacks, mainly from Promises.
- Event loop β the loop that feeds queued callbacks onto the stack when it's empty.
The Call Stack
The call stack tracks where you are in the program. When a function is called it's pushed onto the stack; when it returns it's popped off. Because JavaScript is single-threaded, there is exactly one call stack, and only the frame on top is running.
Watching the stack grow and shrink
function multiplyByTwo(num) {
return num * 2;
}
function calculate(num) {
return multiplyByTwo(num) + 10;
}
function printResult(num) {
const result = calculate(num);
console.log(result);
}
printResult(5); // logs 20
This is Last In, First Out (LIFO): the most recently pushed function is the first to finish. It's also why error messages show a "stack trace" β it's literally a snapshot of the stack when things went wrong.
β οΈ Stack overflow
If functions keep calling without returning β usually runaway recursion with no base case β the stack grows past its limit and you get RangeError: Maximum call stack size exceeded.
function crash(n) {
return crash(n + 1); // never returns β no base case
}
crash(1); // RangeError: Maximum call stack size exceeded
Web APIs: The Background Helpers
Plain JavaScript has no ability to "wait two seconds" or "make a network request." Those powers come from the host environment β the browser (or Node.js) β through Web APIs. When you call one, the work leaves the call stack and runs in the background.
Common Web APIs
- Timers:
setTimeout,setInterval - Networking:
fetch,XMLHttpRequest - DOM events:
click,scroll,keydown - Others: Geolocation, WebSockets, and many more
What happens when you call one
- You hand the Web API a callback (e.g.
setTimeout(cb, 2000)). - The call stack keeps running the rest of your code β it does not wait.
- The Web API does its slow work in the background (counts down the timer).
- When done, it places your callback into the appropriate queue β not directly onto the stack.
console.log('Start');
setTimeout(function timeoutCallback() {
console.log('Runs later (after ~2s)');
}, 2000);
console.log('End');
// Output:
// Start
// End
// Runs later (after ~2s)
π‘ The delay is a minimum, not a promise
setTimeout(cb, 2000) means "run cb no sooner than 2000 ms." If the stack is still busy at the 2-second mark, the callback waits in the queue until the stack is free.
Task Queue vs Microtask Queue
Finished callbacks don't run immediately β they line up in a queue and are processed First In, First Out (FIFO). Crucially, there are two queues with different priorities.
| Queue | Filled by | Priority |
|---|---|---|
| Microtask queue | Promise .then/catch/finally, await continuations, queueMicrotask, MutationObserver |
Higher β drained completely after each task |
| Task queue (macrotasks) | setTimeout, setInterval, DOM events, network callbacks |
Lower β one task per loop turn |
Priority in action
console.log('Start');
setTimeout(() => console.log('Timeout (macrotask)'), 0);
Promise.resolve().then(() => console.log('Promise (microtask)'));
console.log('End');
// Output:
// Start
// End
// Promise (microtask)
// Timeout (macrotask)
Even though both are "immediate," the Promise's microtask runs before the setTimeout macrotask. That's because after the synchronous code finishes, the event loop drains all microtasks before it will pick up even a single macrotask.
π Key Terms
Macrotask: a unit of work from the task queue (a timer callback, an event handler). One runs per loop iteration.
Microtask: a smaller, higher-priority unit (a Promise reaction). The queue is emptied fully before the next macrotask.
The Event Loop Algorithm
The event loop is astonishingly simple for how much it enables. It runs forever, following this cycle:
- Run synchronous code until the call stack is empty.
- Drain the microtask queue completely β run every microtask, including any new microtasks they schedule.
- Take one task from the task queue and push it onto the stack; run it to completion.
- (The browser may render here.)
- Go back to step 2 and repeat.
β The one sentence to remember
Synchronous code first, then all microtasks, then one macrotask β and repeat. Almost every "why did this log in that order?" puzzle is solved by applying that sentence.
Tracing a Full Example
Let's apply the algorithm to a program that mixes all the pieces. Read it, predict the output, then check yourself.
console.log('1: script start');
setTimeout(function timeout1() {
console.log('5: setTimeout 1');
Promise.resolve().then(() => console.log('6: promise inside timeout'));
}, 10);
setTimeout(function timeout2() {
console.log('7: setTimeout 2');
}, 0);
Promise.resolve().then(function promise1() {
console.log('3: promise 1');
Promise.resolve().then(() => console.log('4: promise 2 (nested)'));
});
console.log('2: script end');
Actual output
1: script start
2: script end
3: promise 1
4: promise 2 (nested)
7: setTimeout 2
5: setTimeout 1
6: promise inside timeout
Why that order
- 1, 2 β synchronous code runs first, top to bottom.
- 3, 4 β the stack is empty, so microtasks drain:
promise 1runs and schedules a nested microtask, which also runs before we touch any timer. - 7 β now one macrotask:
setTimeout 2(0 ms) fires beforesetTimeout 1(10 ms). - 5, 6 β the next macrotask,
setTimeout 1, logs and schedules a microtask; that microtask drains immediately after this task, before any further macrotask.
Trace it with the one-sentence rule and it's mechanical, not mysterious.
Keeping the Thread Free
Because everything shares one stack, a long synchronous task starves the queues β no clicks, no rendering, no timers fire. Here's how to stay responsive.
Chunk heavy work
Split a big loop into slices and yield to the event loop between them so the browser can paint and handle input:
function processInChunks(items, work, chunkSize = 100, start = 0) {
const end = Math.min(start + chunkSize, items.length);
for (let i = start; i < end; i++) {
work(items[i]);
}
if (end < items.length) {
// Yield to the event loop, then continue the next chunk
setTimeout(() => processInChunks(items, work, chunkSize, end), 0);
}
}
Offload truly heavy computation to a Web Worker
For CPU-bound work, a Web Worker runs on a genuinely separate thread and communicates by messages, so the main thread stays free:
// main.js
const worker = new Worker('worker.js');
worker.addEventListener('message', (e) => {
console.log('Worker result:', e.data);
});
worker.postMessage(bigDataset);
// worker.js
self.addEventListener('message', (e) => {
const result = doHeavyComputation(e.data);
self.postMessage(result);
});
β οΈ Watch out for microtask starvation
If a microtask keeps scheduling new microtasks, the queue never empties and macrotasks (including rendering) never get a turn. Use microtasks for quick reactions, not unbounded loops.
Hands-on Exercise
ποΈ Predict, Then Verify
Objective: Cement the event-loop rules by predicting output before running the code.
Instructions:
- On paper (or in a comment), predict the exact console order of this snippet:
console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve() .then(() => console.log('C')) .then(() => console.log('D')); queueMicrotask(() => console.log('E')); console.log('F'); - Paste it into your browser's DevTools console (or a
.jsfile) and run it. - Compare your prediction to the real output and explain any surprises using the one-sentence rule.
π‘ Hint
Synchronous logs (A, F) go first. Then all microtasks drain β but a chained .then only schedules its next callback after the previous one resolves, so C and E queue before D does. The lone setTimeout macrotask (B) comes dead last.
β Solution
Output: A, F, C, E, D, B.
A,Fβ synchronous.Cβ first.thenmicrotask;Eβ thequeueMicrotaskcallback (queued while synchronous, so it's in line right afterC).Dβ the second.then, which was only scheduled onceCfinished, so it lands afterE.Bβ the macrotask, after every microtask has drained.
π― Quick Quiz
Question 1: When does the event loop move a callback from a queue onto the call stack?
Question 2: A Promise .then callback and a setTimeout(fn, 0) callback are both ready. Which runs first?
Question 3: Why does a long synchronous while loop freeze the page?
Summary & Quiz
π Key Takeaways
- The runtime has four parts: the call stack, Web APIs, the task queue, and the microtask queue.
- Web APIs do slow work off the stack and place finished callbacks into a queue β never straight onto the stack.
- The event loop runs: synchronous code β all microtasks β one macrotask β repeat.
- Microtasks (Promises) always beat macrotasks (timers, events) when both are ready.
- Never block the single thread β chunk heavy loops or offload to a Web Worker.
π Further Reading
- MDN β The event loop
- Jake Archibald β Tasks, microtasks, queues and schedules
- Node.js β The event loop, timers, and process.nextTick
π What's Next?
Now that you know when callbacks run, we'll look at the oldest way to schedule them: callback functions β their patterns, their pitfalls (hello, callback hell), and why Promises came along.
π You cracked the loop!
Async ordering is now a set of rules you can trace, not a mystery. On to callbacks.