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.
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:
setTimeoutandsetIntervalcallbacks- User input events such as
click,keydown, andinput - Network callbacks delivered as events, like
XMLHttpRequestload handlers postMessageandMessageChannelmessages, 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,.finallycallbacks - The continuation after an
await(which is a promise reaction under the hood) queueMicrotask(fn)MutationObservercallbacks
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:
- Take the oldest runnable macrotask and run it to completion.
- Drain the microtask queue. If a microtask queues another microtask, that one runs too, in this same drain.
- If it is time to paint, run
requestAnimationFramecallbacks, then style, layout, and paint. - 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
| Microtasks | Macrotasks | |
|---|---|---|
| Examples | Promise callbacks, await, queueMicrotask, MutationObserver | setTimeout, setInterval, DOM events, postMessage |
| When they run | After the current task, before rendering | One per event-loop turn |
| Queue drained | Completely, including newly added items | One task at a time |
| Can block rendering | Yes, if the queue never empties | Only for the duration of one task |
| Typical use | Follow-up work that must see a consistent state | Yielding 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:
queueMicrotaskorPromise.resolve().then. - Let the browser render and handle input first:
setTimeout(fn, 0)or aMessageChannelpost. - 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.
Keep reading
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.
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.
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.