· tips  · 7 min read

Microtasks vs Macrotasks: The Great JavaScript Showdown

A deep dive into microtasks and macrotasks in JavaScript: what they are, how the event loop treats them, how they affect rendering and UX, illustrative diagrams and real-world examples, plus practical guidance to avoid performance pitfalls.

A deep dive into microtasks and macrotasks in JavaScript: what they are, how the event loop treats them, how they affect rendering and UX, illustrative diagrams and real-world examples, plus practical guidance to avoid performance pitfalls.

Why this matters

Every web developer eventually runs into a mysterious ordering problem: “Why did my animation not start until after this Promise resolved?” or “Why is my UI frozen despite using Promises?” Understanding microtasks and macrotasks - and how the JavaScript event loop schedules them - is essential for predictable async behavior and snappy user experiences.

This article explains the differences, shows diagrams and real-world scenarios, and offers practical rules to design responsive apps.

Definitions (short)

  • Macrotasks (often just called “tasks”): scheduled work placed into a task queue. Examples: setTimeout, setInterval, setImmediate (Node), I/O callbacks, UI events.
  • Microtasks: very short work scheduled to run after the currently executing script but before rendering and before the next macrotask. Examples: Promise.then / catch / finally, queueMicrotask, MutationObserver callbacks, and process.nextTick (Node-specific) - though process.nextTick has special semantics in Node’s event loop.

References: the MDN event loop explainer and Jake Archibald’s deep dive are excellent reads:

The event loop simplified - lifecycle diagram

A simplified loop for a browser environment:

  1. Take the next macrotask from the macrotask queue and run it (this includes top-level script execution and e.g. a setTimeout callback).
  2. When that macrotask finishes, run all microtasks currently in the microtask queue (and keep running newly enqueued microtasks until the microtask queue is empty).
  3. Optionally render (repaint/reflow) if needed.
  4. Repeat.

ASCII diagram:

[ macrotask N ] --exec--> // runs until completion
                 \__microtasks enqueued____/
                    run all microtasks (empty queue)
                          if (needsPaint) -> paint frame
[ macrotask N+1 ] ...

Key point: microtasks run between macrotasks and must drain completely before rendering or processing the next macrotask.

Quick ordering example

Run this code in the browser console and watch the order of the logs:

console.log('script start');

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

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

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

console.log('script end');

Expected log order:

  1. script start
  2. script end
  3. microtask: promise1
  4. microtask: promise2
  5. macrotask: setTimeout

Why: the top-level script is a macrotask. After it completes, the microtask queue is drained, then the next macrotask (the setTimeout callback) runs.

Real-world scenario 1 - small microtasks that shuffle control immediately

Imagine you have a small state update that must be applied before the UI paints. Microtasks are perfect: use Promises or queueMicrotask to ensure the work finishes before the paint.

Example: batching DOM updates done in a click handler. If you schedule DOM writes in a microtask, they are applied before the browser paints, so the user sees the final state in the same frame.

button.addEventListener('click', () => {
  // do some synchronous work
  // schedule a tiny continuation that must run before paint
  Promise.resolve().then(() => {
    // final DOM update before paint
  });
});

Real-world scenario 2 - microtask starvation (and why you should be careful)

Because the event loop drains microtasks fully before painting, code that continually queues microtasks can starve the rendering and macrotask queues - leading to UI freezes and dropped frames.

A contrived but illustrative example:

function spamMicrotasks() {
  Promise.resolve().then(() => {
    console.log('tick');
    // requeue another microtask indefinitely
    spamMicrotasks();
  });
}

spamMicrotasks();

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

In this example, the microtask queue never empties because each microtask enqueues another microtask. The setTimeout macrotask and the browser paint will not occur - the UI freezes.

Practical takeaway: avoid recursively queuing microtasks without a termination/threshold. For long-running work, chunk it into macrotasks (e.g. setTimeout, requestIdleCallback, or a Web Worker).

Real-world scenario 3 - long macrotasks block everything

If a macrotask runs for too long (for example, a heavy computation inside an event handler), it blocks the main thread, preventing microtasks, rendering, user input handling, and timers from running until it finishes.

Example of a blocking macrotask:

button.addEventListener('click', () => {
  // heavy synchronous work - blocks UI
  const start = performance.now();
  while (performance.now() - start < 2000) {
    // busy-wait 2 seconds
  }
  console.log('heavy work done');
});

This blocks the UI for 2 seconds. Fixes include:

  • Offload to a Web Worker
  • Break work into smaller chunks and schedule via setTimeout(..., 0) or requestIdleCallback
  • Use streaming or incremental algorithms

Example: animation timing - setTimeout vs requestAnimationFrame vs microtasks

You want to animate a DOM element at 60 FPS. Where to schedule frame updates?

  • requestAnimationFrame (rAF): best for visual updates - the browser calls callbacks right before a paint. Use rAF for smooth animation.
  • setTimeout(..., 16) approximates 60 FPS, but it’s not aligned with paint and is clamped when inactive.
  • Microtasks are not a substitute for per-frame updates: they run immediately after JS execution and before paint, but they do not provide a recurring frame tick synchronized to the browser’s refresh.

Example animation loop (correct):

function frame(t) {
  // update DOM state for this frame
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

If you instead used microtasks or setTimeout(0) you will either generate too many synchronous updates (microtasks) or unsynchronized updates (setTimeout) and get janky results.

Node.js differences

Node.js has a different event loop model influenced by libuv. Important differences:

  • process.nextTick runs before other microtasks - it is prioritized and can starve the event loop if abused.
  • setImmediate schedules a macrotask that runs in the check phase.

Official Node docs: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

Be careful with process.nextTick - use it sparingly for internal bookkeeping.

Diagrams: timeline and queues

A simplified timeline for a single frame (browser):

Time ->
[macrotask: event or script start] -> finish
   -> drain microtask queue (all Promise.then / MutationObserver / queueMicrotask)
       -> if microtask queue enqueues more microtasks, continue draining
   -> paint (if layout/paint needed)
-> next macrotask

Queue view:

Macrotask queue: [taskA, taskB, setTimeout(fn), ...]
Microtask queue: [promiseThen1, mutationObserverCallback, ...]

When a macrotask runs, microtasks accumulate; the loop empties microtasks before rendering/next macrotask.

Practical rules and best practices

  1. Use microtasks for tiny continuations that must run immediately after current code (e.g., completing Promise-based state transitions). They are perfect for short, deterministically ordered updates.
  2. Avoid unbounded microtask loops that re-enqueue themselves - they can starve rendering and macrotasks. If you need repeated async work, prefer macrotasks with controlled pacing (setTimeout, requestAnimationFrame, requestIdleCallback).
  3. For UI animations, prefer requestAnimationFrame for per-frame logic. Don’t rely on Promise.then/microtasks or setTimeout(0) for animation timing.
  4. For heavy CPU-bound work, offload to Web Workers or chunk it using macrotasks so the UI can remain responsive.
  5. In Node, be cautious with process.nextTick - it runs before other microtasks and can starve the loop. Use setImmediate or Promises for lower priority continuation.
  6. Use queueMicrotask if you need to explicitly schedule a microtask without creating a Promise.

Common patterns and fixes

  • “UI not updating until after Promise” - expected: microtasks run before paint; if you need the browser to paint first, schedule a macrotask (e.g., setTimeout) or use requestAnimationFrame.

  • “I want something to run after the next paint” - use requestAnimationFrame followed by a macrotask if you need something after paint; or use two rAFs (callback runs before paint), or the experimental window.onafterpaint type patterns (not standardized broadly). Often the pattern is:

requestAnimationFrame(() => {
  // runs before paint
  requestAnimationFrame(() => {
    // runs before paint of the next frame; effectively one paint has occurred in between
  });
});
  • “Avoid blocking long JS on main thread” - use workers or chunking via setTimeout(..., 0) / requestIdleCallback where appropriate.

Appendix: small, runnable ordering examples

Example: mixing MutationObserver, Promise.then and setTimeout:

console.log('script start');

const el = document.createElement('div');
const mo = new MutationObserver(() =>
  console.log('mutation observer callback (microtask)')
);
mo.observe(el, { attributes: true });

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

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

el.setAttribute('data-x', '1'); // triggers MutationObserver microtask

console.log('script end');

Likely ordering:

  • script start
  • script end
  • promise then (microtask)
  • mutation observer callback (microtask)
  • timeout (macrotask)

Note: microtasks enqueued during microtask processing are still processed before the macrotask finishes.

Summary - mental model to keep

  • Macrotask = coarse grain scheduling (events, timers). Microtask = fine-grain immediate continuation (Promises, MutationObserver).
  • After each macrotask finishes, the engine drains the entire microtask queue before rendering or processing the next macrotask.
  • Microtasks can starve rendering if abused. Long macrotasks block everything. Use the correct tool for the job: microtasks for tiny continuations, macrotasks (and workers) for chunked or heavy work, rAF for animations.

Further reading:

Back to Blog

Related Posts

View All Posts »
Bitwise Swapping: A Deeper Dive into JavaScript's Oddities

Bitwise Swapping: A Deeper Dive into JavaScript's Oddities

An in-depth look at swapping variables using the bitwise XOR trick in JavaScript: how it works, why it sometimes bends expectations, practical use-cases, and the pitfalls you must know (ToInt32 conversion, aliasing, typed arrays, BigInt).