JavaScript Concurrency Model
JavaScript has a single call stack - only one function runs at a time. Yet it can handle many concurrent operations (network, timers, events) without blocking. This works because of the event loop.
| Component | What it is | Where code lives |
|---|---|---|
| Call Stack | Single thread - runs JS code | Your JavaScript code |
| Web APIs | Browser/Node native APIs | setTimeout, fetch, addEventListener |
| Microtask Queue | High-priority callbacks | Promise.then, queueMicrotask, MutationObserver |
| Macrotask Queue | Regular async callbacks | setTimeout, setInterval, I/O, UI events |
| Event Loop | Moves tasks to the call stack | The runtime mechanism |
The Call Stack
The call stack is a LIFO (last in, first out) data structure. Each function call pushes a frame; returning pops it. When the stack is empty, the event loop picks the next task.
function third() { console.log('third'); }
function second() { third(); console.log('second'); }
function first() { second(); console.log('first'); }
first();
// Stack trace:
// 1. first() pushed
// 2. second() pushed (called from first)
// 3. third() pushed (called from second)
// 4. third() pops - logs 'third'
// 5. second() pops - logs 'second'
// 6. first() pops - logs 'first'
// Stack empty
// Stack overflow - too many nested calls
function infinite() { return infinite(); }
// infinite(); // RangeError: Maximum call stack size exceeded
// A stack trace error shows you the call path
function a() { b(); }
function b() { c(); }
function c() { throw new Error('oops'); }
try { a(); } catch (e) {
console.log(e.stack);
// Error: oops
// at c (file.js:3)
// at b (file.js:2)
// at a (file.js:1)
}
Web APIs and the Queue
When you call a Web API (setTimeout, fetch), the browser handles it off the main thread. When it completes, the callback goes into a queue - not the call stack directly.
console.log('1 - script starts');
setTimeout(function timeout() {
console.log('4 - setTimeout callback');
}, 0); // 0ms delay - goes to Web API, then macro queue
console.log('2 - after setTimeout call');
// At this point: stack is empty
// Event loop checks queues, picks up timeout callback
console.log('3 - end of script (synchronous)');
// Logs: 1, 2, 3, then 4 (setTimeout fires after all sync code)
// How setTimeout works internally:
// 1. setTimeout() is called - pushes to call stack momentarily
// 2. Browser hands the timer to the Web API
// 3. setTimeout call returns, pops from stack
// 4. Code continues (logs 2 and 3)
// 5. After delay, browser places callback in the macrotask queue
// 6. Event loop sees empty stack, moves callback to stack
// 7. Callback runs (logs 4)
// This is why you cannot "sleep" synchronously in JS:
// while (Date.now() < start + 1000) {} // BAD - blocks the entire thread
// Use setTimeout or await delay() instead
Microtasks
Microtasks run after the current synchronous code and before any macrotask. The microtask queue drains completely (including new microtasks added during draining) before the next macrotask runs.
// Promise .then callbacks are microtasks
console.log('1 - sync');
Promise.resolve().then(() => console.log('3 - microtask 1'));
Promise.resolve().then(() => console.log('4 - microtask 2'));
console.log('2 - sync');
// Output: 1, 2, 3, 4
// All sync code runs first, then all microtasks
// Microtasks added during microtask draining also run before macrotasks
Promise.resolve().then(() => {
console.log('microtask A');
Promise.resolve().then(() => {
console.log('microtask B (added during A)');
// could go infinite - be careful!
});
});
setTimeout(() => console.log('macrotask'), 0);
// Output: microtask A, microtask B, macrotask
// macrotask waits for ALL microtasks including nested ones
// queueMicrotask - explicit microtask scheduling
queueMicrotask(() => console.log('queued microtask'));
console.log('sync after queueMicrotask');
// Output: sync after queueMicrotask, queued microtask
// Sources of microtasks:
// - Promise.then() / Promise.catch() / Promise.finally()
// - await (resumes as a microtask)
// - queueMicrotask()
// - MutationObserver callbacks
Macrotasks
The event loop picks ONE macrotask per iteration, then drains the full microtask queue before picking the next one.
// setTimeout and setInterval create macrotasks
setTimeout(() => console.log('macro 1'), 0);
setTimeout(() => console.log('macro 2'), 0);
Promise.resolve().then(() => console.log('micro 1'));
Promise.resolve().then(() => console.log('micro 2'));
// Order: micro 1, micro 2, macro 1, macro 2
// All microtasks drain first, then macrotasks run one at a time
// setInterval - repeating macrotask
const id = setInterval(() => {
console.log('tick');
}, 1000);
setTimeout(() => clearInterval(id), 5000); // stop after 5 ticks
// Macrotask sources:
// - setTimeout / setInterval callbacks
// - User events (click, keydown, mousemove)
// - I/O callbacks (Node.js)
// - MessageChannel.postMessage
// - requestAnimationFrame (browser rendering queue - separate from macro queue)
// requestAnimationFrame - special: runs before each screen repaint (~60fps)
// More reliable than setTimeout for animations
function animate(timestamp) {
draw(timestamp);
requestAnimationFrame(animate); // schedules next frame
}
requestAnimationFrame(animate);
Execution Order
Putting it all together - the complete execution order with async/await, Promises, and setTimeout.
// Classic interview question - predict the order
console.log('1');
setTimeout(() => console.log('2'), 0); // macrotask
Promise.resolve()
.then(() => console.log('3')) // microtask
.then(() => console.log('4')); // microtask (after 3 settles)
async function asyncFn() {
console.log('5'); // sync (inside async fn, before await)
await Promise.resolve(); // microtask checkpoint
console.log('6'); // microtask (resumes after await)
}
asyncFn();
console.log('7');
// Order: 1, 5, 7, 3, 6, 4, 2
// Explanation:
// Sync: 1 -> asyncFn() starts -> logs 5 -> hits await -> pauses asyncFn -> logs 7
// Microtask drain: Promise chain logs 3 -> asyncFn resumes logs 6 -> chain logs 4
// Macrotask: setTimeout logs 2
// Rule of thumb for predicting order:
// 1. Run ALL synchronous code top to bottom
// 2. Run ALL microtasks (Promise callbacks, await resumes) until queue empty
// 3. Run ONE macrotask (setTimeout callback)
// 4. Go to step 2
Practical Implications
// Implication 1: Long sync tasks block everything
// BAD - blocks rendering and events for the entire loop duration
function expensiveSync(data) {
return data.reduce((acc, x) => acc + heavyCompute(x), 0);
}
// GOOD - yield control via setTimeout for large work
async function expensiveChunked(data, chunkSize = 100) {
let result = 0;
for (let i = 0; i < data.length; i++) {
result += heavyCompute(data[i]);
if (i % chunkSize === 0) {
await new Promise(r => setTimeout(r, 0)); // let browser breathe
}
}
return result;
}
// Implication 2: DOM updates inside Promise chains render in microtasks
// but the ACTUAL repaint happens on the next frame (macro-level)
button.addEventListener('click', async () => {
status.textContent = 'Loading...'; // DOM update
const data = await fetch('/api/data').then(r => r.json());
status.textContent = 'Done'; // DOM update - both visible together on next repaint
});
// Implication 3: queueMicrotask for "just after this synchronous block"
class Observable {
constructor() { this.listeners = []; }
emit(value) {
queueMicrotask(() => {
// notify after current call stack unwinds, before any macrotask
this.listeners.forEach(fn => fn(value));
});
}
}
If your microtask queue never empties (e.g., a microtask keeps scheduling new microtasks in a loop), macrotasks - including rendering and user event callbacks - will never run. This freezes the UI. Always ensure your microtask chains terminate.