
Why Async Code Feels Unpredictable
JavaScript runs code in a single thread — sync, then microtasks, then one macrotask, repeat. Once you see the queues, async stops feeling random.
On this page
- Why setTimeout(fn, 0) Logs Last
- Why the Timer Doesn’t Run Immediately
- Microtasks vs Macrotasks: The Two Queues
- What lands in the microtask queue
- What lands in the macrotask queue
- The Event Loop Order, Step by Step
- Promises vs Timers: A Second Example
- Watch It Run: Event Loop Visualiser
- Where This Causes Real Bugs
- Final Takeaway
At first, async code feels like it has a mind of its own. You write something in one order — and it runs in another. And honestly, it can feel a bit random… until you realise there’s a very consistent pattern behind it.
TL;DR — JavaScript runs all synchronous code, drains the entire microtask queue, then runs one macrotask, and repeats. Because microtasks always finish before the next macrotask starts,
setTimeout(fn, 0)runs after any resolved Promise, which is why async code seems to ignore the order you wrote it in.
Why setTimeout(fn, 0) Logs Last
console.log("Start");
setTimeout(() => {
console.log("Timeout");
}, 0);
console.log("End");If you haven’t seen this before, you’d probably expect:
- Start
- Timeout
- End
But what you actually get is:
- Start
- End
- Timeout
At first, this feels wrong. Why did End print before Timeout? The delay was set to zero milliseconds.
Why the Timer Doesn’t Run Immediately
The key insight is simple, but easy to miss:
Not all code runs immediately. Some things run now. Some are scheduled to run later — even if that “later” is 0 milliseconds.
JavaScript is single-threaded. It can only do one thing at a time. So when it encounters something it can’t run right now — like a timer callback — it doesn’t block. It queues it up and moves on.
Microtasks vs Macrotasks: The Two Queues
JavaScript doesn’t just defer things to “later”. It puts them into different queues, each with a different priority. There are two important ones:
Microtask queue — for things that should run very soon. Resolved Promises land here. This queue drains completely before anything else happens.
Macrotask queue — for things scheduled for later. setTimeout and setInterval callbacks land here. The engine processes one per loop cycle.
What lands in the microtask queue
Promise .then()/.catch()/.finally() callbacks, queueMicrotask(), and await resumptions.
What lands in the macrotask queue
Timer callbacks (setTimeout, setInterval) plus addEventListener and message events.
The Event Loop Order, Step by Step
JavaScript runs code in a single thread. So the flow looks like this:
- Run all synchronous code
- Run all microtasks
- Run one macrotask
- Repeat
That’s it. Applied to our earlier example:
console.log("Start"); // sync → runs now → logs "Start"
setTimeout(..., 0); // macrotask → queued → runs later
console.log("End"); // sync → runs now → logs "End"
// microtasks? none.
// macrotasks? → run Timeout → logs "Timeout"So the result is: Start → End → Timeout
Promises vs Timers: A Second Example
Some async tasks are prioritised over others. For example:
console.log("Start");
setTimeout(() => {
console.log("Timeout");
}, 0);
Promise.resolve().then(() => {
console.log("Promise");
});
console.log("End");This logs:
Start
End
Promise
TimeoutWhy? Because Promises go into the microtask queue, and timeouts go into the macrotask queue — and microtasks always run before macrotasks.
Watch It Run: Event Loop Visualiser
I built a small interactive demo showing the actual execution order step by step. You can watch what gets queued, what runs immediately, and in what order things resolve:
→ Try the Event Loop Visualiser (interactive demo)
Pay attention to two things as you step through: microtasks drain completely before any macrotask runs, and the call stack always clears before either queue is processed.
Where This Causes Real Bugs
Once you understand the ordering, a whole category of confusing bugs start to make sense.
You might write something like this:
setState(newValue);
console.log(state); // logs the old valueYou expected to see the updated value. But setState in React (and many other frameworks) schedules updates asynchronously — the state hasn’t changed yet when the console.log runs. This is why state updates seem to lag behind whatever you do to your store.
It’s not broken. It’s the event loop doing exactly what it’s supposed to.
The same pattern causes unexpected ordering, stale closures, and things that seem to happen “too late”. Once you see the queue model, each of these clicks into place.
Final Takeaway
Async code isn’t unpredictable. It just doesn’t run in the order you write it.
Once you understand:
synchronous → microtasks → macrotasks
…a lot of confusing behaviour suddenly makes sense.
Originally published on Medium .
2026 © Jordan O’Hara

