JavaScript Advanced #4 Event Loop and Tasks

In Intermediate #2 you saw how to use Promise and async/await. This post looks at how that async actually works — the event loop and task queues.

JavaScript is single-threaded #

First the most important fact — JavaScript executes one line at a time. While one function runs, another can never cut in.

sync execution
function a() {
  b();
  console.log('a end');
}
function b() {
  console.log('b end');
}

a();
// b end
// a end

The data structure that makes this work is the call stack. Functions push when called and pop when they return.

The next async runs only when the call stack is empty #

The key rule for how JavaScript handles async:

The next async task runs only when the call stack is empty.

check the order
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');

// 1, 3, 2 (this order)

Even with setTimeout(..., 0), 2 is last. The reason: all sync code on the call stack must finish first before the timer callback runs — even with a delay of 0ms.

The event loop #

What manages this is the event loop. Simplified, it does the following endlessly.

event loop pseudocode
while (true) {
  if (call stack empty) {
    if (microtask queue not empty) {
      // run all microtasks until the queue is empty
      run all microtasks;
    } else if (macrotask queue not empty) {
      take one macrotask and run;
    }
  }
}

Two key points.

  1. The queue is checked only when the call stack is empty — sync code first
  2. Microtasks run before macrotasks

Microtask vs macrotask #

JavaScript async splits into two queues.

KindWhere they come from
Macrotask (Task)setTimeout, setInterval, I/O, UI events
MicrotaskPromise.then/.catch/.finally, queueMicrotask, the part after await

Microtasks run before macrotasks. And until the microtask queue is empty — running every available microtask.

Key example #

comparing run order
console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// output order: 1, 4, 3, 2

Explanation:

  1. console.log('1') — sync run
  2. setTimeout — registered to macrotask queue
  3. Promise.resolve().then(...) — registered to microtask queue
  4. console.log('4') — sync run
  5. Call stack empty → check microtask queue → output 3
  6. Microtask queue empty → check macrotask queue → output 2

That’s why a 0ms setTimeout runs later than Promise.then.

The part after await is also a microtask #

async/await behavior
async function a() {
  console.log('1');
  await null;
  console.log('3');
}

console.log('start');
a();
console.log('2');

// start, 1, 2, 3

Inside a(), when await null is hit — the function pauses there and the next line (console.log('3')) is registered as a microtask. Control returns to the caller, console.log('2') runs, and then the microtask runs.

That’s how async/await actually works.

queueMicrotask — make microtasks directly #

queueMicrotask
console.log('1');
queueMicrotask(() => console.log('2'));
console.log('3');

// 1, 3, 2

Both browser and Node have this. Shorter and clearer than Promise.resolve().then(...) for creating microtasks. Sometimes used inside library implementations.

Same queue is FIFO #

Tasks within the same queue are processed in arrival order.

order preserved within a queue
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
setTimeout(() => console.log('C'), 0);
// A, B, C
same for microtasks
Promise.resolve().then(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
Promise.resolve().then(() => console.log('C'));
// A, B, C

One macrotask vs all microtasks #

To emphasize — for each macrotask, all microtasks until the queue is empty are processed before moving to the next macrotask.

mixing
setTimeout(() => {
  console.log('macro 1');
  Promise.resolve().then(() => console.log('micro 1'));
}, 0);

setTimeout(() => console.log('macro 2'), 0);

// macro 1, micro 1, macro 2

When macro 1 runs, micro 1 is queued. After macro 1 finishes and the call stack is empty — before going to the macrotask queue, the microtask queue drains first. So micro 1 prints before macro 2.

Things to watch in practice #

1) Endless microtasks → starve macrotasks #

Endlessly scheduling microtasks inside microtasks blocks macrotasks and UI updates.

don't do this
function loop() {
  Promise.resolve().then(loop);
}
loop();
// browser freezes — no other events, no rendering

Doing the same with setTimeout is a macrotask, so the UI gets a chance. Microtasks have higher priority and are therefore riskier in this regard.

2) DOM updates and microtasks #

DOM changes inside Promise.resolve().then(...) are applied before rendering. Multiple DOM changes within the same frame still render once.

DOM with microtasks
button.addEventListener('click', () => {
  el.textContent = '1';
  Promise.resolve().then(() => {
    el.textContent = '2';   // user only sees '2'
  });
});

Part of why libraries like React can use microtasks for batched updates.

3) Sync code between awaits #

briefly paused at await
async function process() {
  console.log('1');
  await fetch('/api');
  console.log('2');   // runs only after fetch finishes
}

process();
console.log('3');     // runs while process is paused at await

When process() hits await, it pauses. The caller (console.log('3')) gets a chance to run. Once fetch finishes, process’s next line is queued as a microtask.

requestAnimationFrame and requestIdleCallback #

In the browser, beyond macrotasks there are two callbacks synchronized with rendering.

requestAnimationFrame — runs on the next frame #

rAF
requestAnimationFrame(() => {
  // runs just before the next frame is painted (typically 60fps = 16.7ms)
  el.style.transform = `translateX(${x}px)`;
});

Fits animations and grouping DOM changes. More accurately frame-aligned than setTimeout.

requestIdleCallback — runs when idle #

rIC
requestIdleCallback(() => {
  // runs when the main thread is idle — low-priority work
  saveAnalytics();
});

Fits non-urgent work (analytics save, background precompute). Doesn’t block user interaction.

Node’s event loop — slightly different #

Node has an event loop too, but with more granular phases.

PhaseHandles
timerssetTimeout / setInterval callbacks
I/O callbackssome system callbacks
pollreceive new I/O events
checksetImmediate callbacks
closesocket.on('close', ...)

There are also Node-only tools like setImmediate and process.nextTickprocess.nextTick runs even before microtasks. Rarely needed in everyday code.

Wrap-up #

What we covered:

  • JavaScript is single-threaded; async runs only when the call stack is empty
  • Event loop — sync until the stack is empty, then microtasks → macrotasks
  • Microtasks — Promise.then, queueMicrotask, the part after await
  • Macrotasks — setTimeout, I/O, UI events
  • After one macrotask, drain microtasks until empty
  • Endless microtasks block the UI
  • requestAnimationFrame / requestIdleCallback sync with rendering
  • Node’s event loop has more granular phases

In the next post (#5 Memory Model and GC) we cover how JavaScript manages memory — how the garbage collector works and the patterns that cause leaks.

X