Articles

Microtasks vs Macrotasks in JavaScript, Explained

Microtasks (promise callbacks) run before the next macrotask (timers, events). How the two queues are ordered, and why it matters for your code.

Takina Takina · · 5 min read
Dark-themed code editor showing JavaScript code

Microtasks and macrotasks are the two kinds of queued work that JavaScript’s event loop schedules. A macrotask (the HTML spec just calls it a task) is a discrete unit of work such as a setTimeout callback, a click handler, or a message from a worker. A microtask is a smaller follow-up job, such as a promise reaction or a queueMicrotask callback. The rule that explains almost every ordering puzzle: after each macrotask finishes, the engine drains the entire microtask queue before it does anything else, including rendering or picking up the next macrotask.

If you have read our overview of the JavaScript event loop, this is the detail that sits underneath it.

What counts as a macrotask?

A macrotask is anything the browser or runtime queues as a standalone job. Common sources:

  • setTimeout and setInterval callbacks
  • User input events such as click, keydown, and input
  • Network callbacks delivered as events, like XMLHttpRequest load handlers
  • postMessage and MessageChannel messages, including messages from a web worker
  • Running the initial <script> itself

The browser may keep several task queues (input events are often prioritised over timers, for example) and picks one task per loop iteration. Between tasks, it gets an opportunity to render.

What counts as a microtask?

Microtasks are jobs that need to run “as soon as the current code finishes” but not synchronously. The main sources:

  • Promise reactions: .then, .catch, .finally callbacks
  • The continuation after an await (which is a promise reaction under the hood)
  • queueMicrotask(fn)
  • MutationObserver callbacks

Node.js adds process.nextTick, which has its own queue that drains even before the promise microtask queue.

The ordering rule, step by step

One turn of the event loop looks roughly like this:

  1. Take the oldest runnable macrotask and run it to completion.
  2. Drain the microtask queue. If a microtask queues another microtask, that one runs too, in this same drain.
  3. If it is time to paint, run requestAnimationFrame callbacks, then style, layout, and paint.
  4. Go back to step 1.

Here is the classic demonstration:

console.log("script start");

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

Promise.resolve()
  .then(() => console.log("promise 1"))
  .then(() => console.log("promise 2"));

queueMicrotask(() => console.log("microtask"));

console.log("script end");

The output is:

script start
script end
promise 1
microtask
promise 2
timeout

The script is itself a macrotask, so its synchronous logs run first. When it ends, the microtask queue holds promise 1 and microtask, in the order they were queued. Running promise 1 queues promise 2, which joins the back of the still-draining queue. Only when the queue is empty does the loop move on to the timer, even though it was scheduled with a delay of zero.

Microtasks vs macrotasks at a glance

MicrotasksMacrotasks
ExamplesPromise callbacks, await, queueMicrotask, MutationObserversetTimeout, setInterval, DOM events, postMessage
When they runAfter the current task, before renderingOne per event-loop turn
Queue drainedCompletely, including newly added itemsOne task at a time
Can block renderingYes, if the queue never emptiesOnly for the duration of one task
Typical useFollow-up work that must see a consistent stateYielding to the browser, deferring work

Why the distinction matters in practice

Promises do not yield to the browser

A common misconception is that wrapping work in a promise makes it “async” in the sense of letting the page breathe. It does not. Every promise callback runs in the microtask drain, before the browser can paint or handle input. A loop that keeps chaining promises can freeze a page just as effectively as a while (true):

function spin() {
  return Promise.resolve().then(spin); // never lets the browser render
}

If you want to break up long work so the page stays responsive, you need to yield to a macrotask, for example with setTimeout(resolve, 0) or a MessageChannel, or use a dedicated scheduling API such as requestIdleCallback. This is directly tied to responsiveness metrics covered in our Core Web Vitals guide: long tasks, including long microtask drains, delay the next interaction.

await resumes sooner than you might think

After an await on an already-resolved value, the rest of the async function runs as a microtask, so it still executes before any pending timers or events. That is usually what you want, because state stays consistent. But it also means a long series of await calls on cached values never gives the browser a chance to render. Our async/await vs promises piece explains why the two behave identically here.

Batching DOM updates

Frameworks lean on microtasks to batch work. If several state changes happen in the same task, the framework can schedule one microtask to flush them all, and the DOM is updated once, before the browser paints. The user never sees an intermediate state. MutationObserver is built on the same idea: it collects every mutation from a task and reports them together in a single microtask.

Error-handling timing

Because promise rejections are processed in microtasks, an “unhandled rejection” is only reported once the microtask queue has drained without a handler being attached. Attaching a .catch later in a setTimeout is too late; the runtime will already have flagged it.

Choosing the right scheduling tool

  • Run right after the current code, before paint: queueMicrotask or Promise.resolve().then.
  • Let the browser render and handle input first: setTimeout(fn, 0) or a MessageChannel post.
  • Run just before the next frame: requestAnimationFrame.
  • Run when the main thread is idle: requestIdleCallback.
  • Run heavy computation without blocking at all: move it to a worker.

Prefer queueMicrotask over Promise.resolve().then when you do not need a promise. It states the intent directly, and errors thrown inside it are reported as ordinary uncaught exceptions rather than promise rejections.

The takeaway

Macrotasks are the event loop’s units of work; microtasks are follow-up jobs that run immediately after each one. The microtask queue is drained completely, including anything added during the drain, before the browser renders or takes the next task. That makes microtasks ideal for consistent, batched follow-up work and a poor choice for keeping a page responsive. When you need to give the browser room to breathe, yield to a macrotask instead.

Takina Takina · · 6 min read

How to Break Up Long Tasks in JavaScript

Long tasks block the main thread for 50ms or more and make pages feel frozen. How to split them with yielding, scheduler.yield(), postTask, and workers.

#JavaScript #Performance #Web Development
Takina Takina · · 5 min read

OffscreenCanvas API Explained

OffscreenCanvas lets you render canvas graphics off the main thread, in a web worker, so heavy drawing work stops blocking scrolling, input, and animation.

#JavaScript #Web Development #Performance
Takina Takina · · 3 min read

What Is a SharedWorker? Explained

A SharedWorker runs one script shared across every tab and window of the same origin — a single background thread they can all connect to and message.

#JavaScript #Web Development #Performance