All posts
A developer seen from behind, hand to chin, reading code on a laptop and a large monitor
Photo by olia danilevich

Posted 4 min read Updated

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.

javascriptprogrammingsoftware-engineeringweb-development
On this page

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:

But what you actually get is:

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:

  1. Run all synchronous code
  2. Run all microtasks
  3. Run one macrotask
  4. 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 Timeout

Why? 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 value

You 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