- Describe the call stack, the task queue and the microtask queue
- Predict the output order of code that mixes synchronous code,
setTimeout, promises andawait - Choose between sequential and parallel async code:
Promise.all,allSettled,race,any
A classic interview question: “In what order does this code print?” — followed by a mix of console.log, setTimeout(..., 0) and Promise.then. Even experienced developers often get it wrong. JavaScript runs on a single thread, yet it handles thousands of clicks, requests and timers. The secret is the event loop. Once you understand three structures — the call stack, the task queue and the microtask queue — such puzzles become simple arithmetic.
The call stack
The call stack is the list of functions that are running right now. Calling a function puts a new “frame” on top of the stack; returning removes it. JavaScript has one stack, so it does only one thing at a time. While the stack is busy, nothing else can happen: no clicks are handled, no timers fire and the page is not redrawn.
function third() {
console.log('3. third is on top of the stack');
}
function second() {
console.log('2. second calls third');
third();
console.log('4. back in second');
}
function first() {
console.log('1. first calls second');
second();
console.log('5. back in first');
}
first();▸ Expected output
1. first calls second 2. second calls third 3. third is on top of the stack 4. back in second 5. back in first
first → second → third. Each function continues only after the call inside it has finished. Endless recursion fills the stack, and the engine throws an error.Tasks and microtasks
When async work (a timer, a network response, a click) finishes, its callback doesn't run at once — it waits in a queue. There are two main queues. The task queue (macrotasks) receives setTimeout, setInterval, user events and messages. The microtask queue receives promise callbacks (then, catch, finally), the code after await and queueMicrotask. The event loop works through them like this:
- 1Run the current code to the end
The script (or one task) runs until the stack is empty.
- 2Run ALL microtasks
Including new microtasks added along the way — until the queue is completely empty.
- 3Render the page if needed
The browser puts the changes on the screen.
- 4Take ONE task from the queue
For example, one timer callback — and go back to step 1.
console.log('A: script start');
setTimeout(() => console.log('B: timeout'), 0);
Promise.resolve()
.then(() => console.log('C: promise 1'))
.then(() => console.log('D: promise 2'));
queueMicrotask(() => console.log('E: microtask'));
console.log('F: script end');▸ Expected output
A: script start F: script end C: promise 1 E: microtask D: promise 2 B: timeout
async/await in the queue
await pauses only its own async function; the code after await continues as a microtask. Note: the code before the first await runs synchronously — as soon as the function is called.
async function load() {
console.log('2. load starts: synchronous part');
await null;
console.log('4. after await: microtask');
}
console.log('1. script start');
setTimeout(() => console.log('6. timeout: task'), 0);
load();
Promise.resolve().then(() => console.log('5. then: microtask'));
console.log('3. script end');▸ Expected output
1. script start 2. load starts: synchronous part 3. script end 4. after await: microtask 5. then: microtask 6. timeout: task
The microtask queue is emptied completely before the next task. That is why a promise created inside a timer runs before the next timer. For the same reason, a microtask that keeps scheduling itself forever would freeze the page just like an endless while loop.
setTimeout(() => {
console.log('timeout 1');
Promise.resolve().then(() => console.log('-> microtask inside timeout 1'));
}, 0);
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve().then(() => {
console.log('microtask 1');
setTimeout(() => console.log('timeout 3, added by microtask 1'), 0);
});▸ Expected output
microtask 1 timeout 1 -> microtask inside timeout 1 timeout 2 timeout 3, added by microtask 1
Sequential and parallel
JavaScript is single-threaded, but waiting can overlap: the browser (or Node.js) tracks network requests and timers in the background. This is called concurrency: many jobs are “in progress” at once, but at any moment only one piece of JavaScript runs. A common mistake: await inside a loop sends requests one after another. If the jobs are independent, start them all together and wait with Promise.all.
const task = (label, ms) =>
new Promise((resolve) => {
setTimeout(() => {
console.log(`${label} done`);
resolve(label);
}, ms);
});
console.log('Sequential:');
await task('A (200 ms)', 200);
await task('B (100 ms)', 100);
console.log('Parallel:');
const results = await Promise.all([task('A (200 ms)', 200), task('B (100 ms)', 100)]);
console.log(results);▸ Expected output
Sequential: A (200 ms) done B (100 ms) done Parallel: B (100 ms) done A (200 ms) done [ 'A (200 ms)', 'B (100 ms)' ]
Promise.all returns the results in array order. Top-level await works here, as it does in modules.| Method | When it settles | Result |
|---|---|---|
Promise.all | when all succeed, or at the first failure | an array of values, or the first error |
Promise.allSettled | when all have finished; never rejects | an array of { status, value } / { status, reason } |
Promise.race | when the first one settles (success or failure) | the fastest outcome |
Promise.any | at the first success; if all fail, an error | the first successful value, or AggregateError |
const ok = (value, ms) =>
new Promise((resolve) => setTimeout(() => resolve(value), ms));
const fail = (reason, ms) =>
new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));
const settled = await Promise.allSettled([ok('menu', 100), fail('no stock', 50)]);
console.log(settled.map((result) => result.status));
console.log(await Promise.any([fail('server 1 down', 50), ok('server 2', 100)]));
console.log(await Promise.race([ok('fast', 50), ok('slow', 150)]));
try {
await Promise.all([ok('menu', 100), fail('no stock', 50)]);
} catch (error) {
console.log('Promise.all failed:', error.message);
}▸ Expected output
[ 'fulfilled', 'rejected' ] server 2 fast Promise.all failed: no stock
Without moving any lines, make the output come in the order 1, 2, 3, 4: wrap the right lines in setTimeout(() => ..., 0) or Promise.resolve().then(() => ...).
console.log('4. task');
console.log('3. microtask');
console.log('1. sync');
console.log('2. sync');▸ Expected output
1. sync 2. sync 3. microtask 4. task
The prices are loaded one after another, which is slow. Change the code so that all three requests start at the same time: use Promise.all and array destructuring. The loading order will change, but the total stays the same.
const prices = { tea: 2, bread: 1, jam: 5 };
function getPrice(item, ms) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`loaded ${item}`);
resolve(prices[item]);
}, ms);
});
}
// sequential and slow: rewrite with Promise.all
const tea = await getPrice('tea', 300);
const bread = await getPrice('bread', 100);
const jam = await getPrice('jam', 200);
console.log('Total:', tea + bread + jam);▸ Expected output
loaded bread loaded jam loaded tea Total: 8
Key points
- JavaScript has one call stack: only one piece of code runs at a time, and long synchronous work freezes everything.
- Timers and events go to the task queue; promise callbacks and the code after
awaitgo to the microtask queue. - The order: synchronous code → all microtasks → one task → all microtasks again.
- In an
asyncfunction, the code before the firstawaitruns synchronously. - Start independent jobs together and wait with
Promise.all/allSettled;raceandanytake the first result.
Check yourself
10 questions. Every correct answer earns XP.
console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3)); console.log(4); print, and in what order?