The JavaScript engine

Tasks and microtasks

Understand how event callbacks, timers, promise reactions, and rendering opportunities are scheduled around the JavaScript stack.

The event loop decides when queued JavaScript gets to use the main thread. It works with two kinds of queues, and the difference between them explains a lot of confusing output.

A task is a unit of work the browser schedules: a click handler, a timer callback, the initial run of a script. Tasks wait in the task queue.

A microtask is smaller and more urgent: a promise reaction, or a callback passed to queueMicrotask(). Microtasks wait in their own queue.

Predict the output of this code before running it:

console.log('start')

setTimeout(() => console.log('task'), 0)

Promise.resolve().then(() => console.log('microtask'))

console.log('end')

The result is:

start
end
microtask
task

Here’s why. The current task, the script itself, runs to completion. That gives us start and end. When its call stack is empty, the browser drains the microtask queue, so microtask prints. Only then does it pick the next task from the task queue. The timer callback is a task, so it goes last, even with a delay of zero.

The rule: after every task, all pending microtasks run before the next task starts.

Rendering fits between tasks

Between tasks, after the microtasks have run, the browser may get a rendering opportunity: a moment to run style, layout, and paint, and put a frame on screen.

That “may” matters. A setTimeout is not a promise that a frame will be painted. If you need code to run right before a paint, use requestAnimationFrame().

Microtasks can starve the page

Microtasks are great for finishing a small piece of related work before anything else runs. They become dangerous when they keep scheduling more microtasks:

function repeat() {
  queueMicrotask(repeat)
}

repeat()

The queue never drains. The browser never gets to the next task, so clicks aren’t handled, timers don’t fire, nothing is painted. The tab is frozen, and no single function took long. Each microtask was tiny. There were just infinitely many.

The same happens with a promise chain that resolves in a loop without ever awaiting a real task.

My advice: keep microtasks bounded. When you have a lot of work, split it across later tasks with setTimeout, or move it to a Web Worker.

Try this on your own project: add a console.log in a click handler, in a setTimeout with delay zero, and in a .then(), all triggered by the same click. Predict the order, then check the Console.

Lesson completed