Skip to content
Educora
Advanced22 min17 / 27

The event loop and concurrency

Look inside the engine: the call stack, the task queue and the microtask queue. Learn to predict the exact order of `setTimeout`, promises and `await`.

Check yourself
In this lesson you will learn
  • Describe the call stack, the task queue and the microtask queue
  • Predict the output order of code that mixes synchronous code, setTimeout, promises and await
  • 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.

JavaScript
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
The stack: 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:

  1. 1
    Run the current code to the end

    The script (or one task) runs until the stack is empty.

  2. 2
    Run ALL microtasks

    Including new microtasks added along the way — until the queue is completely empty.

  3. 3
    Render the page if needed

    The browser puts the changes on the screen.

  4. 4
    Take ONE task from the queue

    For example, one timer callback — and go back to step 1.

JavaScript
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
First the synchronous A and F. Then microtasks: C and E were already queued, and D was added when C finished. Timer B comes last — as a separate task.

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.

JavaScript
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.

JavaScript
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.

JavaScript
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)' ]
The sequential version takes ~300 ms, the parallel one ~200 ms; in parallel B finishes first, yet Promise.all returns the results in array order. Top-level await works here, as it does in modules.
MethodWhen it settlesResult
Promise.allwhen all succeed, or at the first failurean array of values, or the first error
Promise.allSettledwhen all have finished; never rejectsan array of { status, value } / { status, reason }
Promise.racewhen the first one settles (success or failure)the fastest outcome
Promise.anyat the first success; if all fail, an errorthe first successful value, or AggregateError
JavaScript
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
Exercise

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(() => ...).

Exercise · JavaScript
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
Exercise

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.

Exercise · JavaScript
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 await go to the microtask queue.
  • The order: synchronous code → all microtasks → one task → all microtasks again.
  • In an async function, the code before the first await runs synchronously.
  • Start independent jobs together and wait with Promise.all/allSettled; race and any take the first result.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
What does console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3)); console.log(4); print, and in what order?