Frontend Development 13 min read

Mastering JavaScript Macrotasks, Microtasks, and Promise Timing

This article explains the differences between macrotasks and microtasks in the JavaScript event loop, shows how they affect Promise execution, provides practical code examples, and discusses how frameworks like Vue implement task scheduling, helping developers avoid dead‑loops and improve performance.

Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
Mastering JavaScript Macrotasks, Microtasks, and Promise Timing

Google Developer Day China 2018 by Jake Archibald

Although I did not attend the event, I read a detailed summary of the browser event loop presented by Baidu's 小蘑菇小哥 and added my own notes.

Below is a concise classification of asynchronous tasks:

Tasks (Macrotasks) – executed one at a time during the current event loop; new tasks created during the loop are queued for later execution. Common sources:

setTimeout

,

setInterval

,

setImmediate

, I/O.

Animation callbacks – run before the render steps (Structure‑Layout‑Paint). All current‑loop tasks are executed first; any new tasks created run in the next loop. Source:

requestAnimationFrame

.

Microtasks – executed immediately after the current script finishes, before the next macrotask. The queue is drained completely before the loop ends, and any microtasks created while processing are added to the same queue. Sources:

Promise

,

Object.observe

,

MutationObserver

,

process.nextTick

.

Intuitive Demo of Macrotasks vs. Microtasks

The distinction between “mtask” and “task” is debated; some argue there is only one task queue.

For developers new to JavaScript async patterns, the following recursive example illustrates a dead‑loop caused by blocking the event loop:

<ol><li><code>// ordinary recursion causing a dead‑loop, page becomes unresponsive</code></li><li><code>function callback() {</code></li><li><code>    console.log('callback');</code></li><li><code>    callback();</code></li><li><code>}</code></li><li><code>callback();</code></li></ol>

Wrapping the recursive call in

setTimeout

moves it to the macrotask queue, preventing the UI from freezing:

<ol><li><code>// Macrotask version – no dead‑loop</code></li><li><code>function callback() {</code></li><li><code>    console.log('callback');</code></li><li><code>    setTimeout(callback, 0);</code></li><li><code>}</code></li><li><code>callback();</code></li></ol>

When the same logic is placed inside a

Promise

(a microtask), the dead‑loop reappears because microtasks are executed immediately:

<ol><li><code>// Microtask version – still causes a dead‑loop</code></li><li><code>function callback() {</code></li><li><code>    console.log('callback');</code></li><li><code>    Promise.resolve().then(callback);</code></li><li><code>}</code></li><li><code>callback();</code></li></ol>

Key points:

Microtasks run at the end of the current event loop before it finishes.

The loop does not end until the microtask queue is empty.

If a microtask creates another microtask, the loop can never exit, leading to a dead‑loop.

Microtasks and Promise A+

Promises execute their executor function immediately (synchronously) and schedule

then

callbacks as microtasks. The following example demonstrates that the promise body runs right away, while the

then

handler runs after the current script:

<ol><li><code>var d = new Date();</code></li><li><code>// Create a promise that resolves after 2 seconds</code></li><li><code>var promise1 = new Promise(function(resolve, reject) {</code></li><li><code>    setTimeout(resolve, 2000, 'resolve from promise 1');</code></li><li><code>});</code></li><li><code>// Create a promise that resolves after 1 second and resolves promise1</code></li><li><code>var promise2 = new Promise(function(resolve, reject) {</code></li><li><code>    setTimeout(resolve, 1000, promise1); // resolve(promise1)</code></li><li><code>});</code></li><li><code>promise2.then(result => console.log('result:', result, new Date() - d));</code></li></ol>

Output shows that the executor runs immediately, while the

then

callback is deferred.

Two conclusions:

It validates the Promise/A+ 2.3.2 requirement that

then

callbacks are asynchronous.

Promise constructors execute synchronously (the 2‑second timer starts right away).

The spec also notes that platform code may implement the asynchronous step using either a macrotask (

setTimeout

,

setImmediate

) or a microtask (

MutationObserver

,

process.nextTick

).

Why Microtasks Matter

Microtasks are ideal for operations that must happen immediately after the current script, such as batching updates or performing lightweight async work without the overhead of a full macrotask.

Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task.

The event loop processes tasks in this order:

Take one macrotask from the queue and execute it.

Drain the entire microtask queue, executing each microtask in order.

When both queues are empty, the loop ends and a new macrotask is taken.

This explains why

Promise.then

is implemented as a microtask: the callback runs as soon as possible after the current script, without waiting for the next macrotask.

Practical Application in Vue

Vue’s

src/core/utils/next-tick.js

implements both macrotask and microtask strategies to schedule callbacks. The code chooses the fastest available mechanism (e.g.,

setImmediate

,

MessageChannel

, or

setTimeout

) and falls back gracefully when necessary.

<ol><li><code>let microTimerFunc;</code></li><li><code>let macroTimerFunc;</code></li><li><code>if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {</code></li><li><code>  macroTimerFunc = () => { setImmediate(flushCallbacks); };</code></li><li><code>} else if (typeof MessageChannel !== 'undefined' && isNative(MessageChannel)) {</code></li><li><code>  const channel = new MessageChannel();</code></li><li><code>  const port = channel.port2;</code></li><li><code>  channel.port1.onmessage = flushCallbacks;</code></li><li><code>  macroTimerFunc = () => { port.postMessage(1); };</code></li><li><code>} else {</code></li><li><code>  macroTimerFunc = () => { setTimeout(flushCallbacks, 0); };</code></li><li><code>}</code></li><li><code>if (typeof Promise !== 'undefined' && isNative(Promise)) {</code></li><li><code>  const p = Promise.resolve();</code></li><li><code>  microTimerFunc = () => { p.then(flushCallbacks); };</code></li><li><code>}</code></li></ol>

This implementation ensures that Vue’s reactivity system updates efficiently across different browsers and environments.

Note: Some older browsers implement Promise callbacks as macrotasks, which can affect timing and performance.

frontendJavaScriptevent-loopPromiseMicrotasksmacrotasks
Tencent IMWeb Frontend Team
Written by

Tencent IMWeb Frontend Team

IMWeb Frontend Community gathering frontend development enthusiasts. Follow us for refined live courses by top experts, cutting‑edge technical posts, and to sharpen your frontend skills.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.