Your setTimeout(fn, 0) Doesn't Run Next. queueMicrotask Does.
setTimeout(fn, 0) feels like 'run this right after the current code.' It isn't — every pending promise cuts in line first. queueMicrotask puts your callback in that same fast lane, on purpose.

The test called the function, then did await Promise.resolve(), then checked the state. It failed. Not flaky — failed every single run, for this one function and no other.
The function wasn't broken. It deferred its work with setTimeout(fn, 0), and await Promise.resolve() doesn't wait for that. It can't. By the time setTimeout even gets a turn, that await is long finished.
The fix was a one-word swap. Understanding why it mattered took longer.
Here's the function in question — a tiny update batcher for a settings panel. Call it five times in a row and it should write once, not five times:
Click a few toggles fast, and flush runs once with the final values. Manual testing looks perfect. Then someone writes the obvious test:
That await Promise.resolve() is a common idiom for "let the microtask queue drain before I assert." It isn't broken, and it isn't a flake you can retry away. flush genuinely has not run yet when the assertion checks — because setTimeout, even at 0, schedules into a queue that a microtask-flushing await never visits.
Every JavaScript engine actually runs two separate queues, and they are not interchangeable:
- The microtask queue — where
Promise.resolve().then(fn)andqueueMicrotask(fn)both land, in the same queue. - The macrotask (task) queue — where
setTimeout, UI events, and I/O callbacks land.
The HTML spec's event loop rule is blunt about the order: once the current script finishes, the engine drains the entire microtask queue — including any new microtasks queued while draining — before it's allowed to touch the next macrotask, or render a frame. This isn't "promises happen to be fast." It's a hard ordering guarantee, and every major engine (V8, SpiderMonkey, JavaScriptCore) implements it the same way.
The classic proof:
That part you may already know. Here's the part that surprises people: it's not a head start, it's a standing rule. Chain ten .then() calls after that same setTimeout, each one scheduling the next only once the previous resolves, and all ten still finish before the timeout fires — because the microtask queue keeps draining as long as anything is in it, and a .then() callback adds its continuation back onto that same queue. The setTimeout doesn't get a turn until the queue is empty, however long that takes.
Runs right in your browser — poke at it and watch the concept react live.
Swap the one line:
queueMicrotask still coalesces every synchronous call in the current burst into a single flush — that part of the behavior doesn't change, because the microtask checkpoint only runs once the call stack is empty either way. What changes is which queue flush sits in. Now it's the same queue await Promise.resolve() empties. The test passes, because flush — queued first — runs before the test's own await gets its turn, not after.
queueMicrotask isn't exotic: it's Baseline, widely supported since 2020 in every major browser, and it's been a global in Node since v11. It takes a callback, returns nothing, and that's the entire API.
Don't read this as "replace every setTimeout(0)." Microtasks are not free — the queue has to fully drain before the browser can render a frame or respond to a click, so a microtask that keeps re-queuing itself can freeze the page solid. If what you actually need is to let the browser breathe — paint a frame, handle an input event — between chunks of work, you want a macrotask-shaped yield (setTimeout, or scheduler.yield where it's available), not a microtask. queueMicrotask answers "run this before anything else gets a turn." It is the wrong tool for "give the user a moment."
One more gotcha worth knowing if you touch Node: process.nextTick() jumps the line even ahead of queueMicrotask — Node fully drains its nextTick queue before the promise/microtask queue on every turn. If you're writing isomorphic code, queueMicrotask is the one that behaves the same in the browser and in Node; process.nextTick doesn't exist in the browser at all.
The test wasn't wrong, and neither was the original instinct to defer the flush. setTimeout(fn, 0) was just answering a different question than the one being asked — "run after the next paint," instead of "run after absolutely everything currently promised, and not one tick later." queueMicrotask is the second sentence, spelled correctly.
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Where else in your code is a setTimeout(fn, 0) quietly promising "right after" but delivering "eventually"? I'd bet there's at least one test file that already knows.
- My .catch() Never Ran. The Bug Was Two Lines Above It
- Stop Retrying Immediately. Exponential Backoff Fixes It.
- You're Debouncing Scroll Wrong. requestAnimationFrame Fixes It.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.