Why Your JavaScript Runs in the Wrong Order

Copy this into your browser console: console.log('1'); setTimeout(() => console.log('2'), 0); Promise.resolve().then(() => console.log('3')); console.log('4'); You probably expect: 1, 2, 3, 4. You get: 1, 4, 3, 2. Nothing is broken. JavaScript is doing exactly what it was designed to do. The confusing part is that the language looks like it runs top to bottom, but it does not always work that way. In this article you will learn three ideas that explain almost every async surprise in JavaScript:
Copy this into your browser console:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
Enter fullscreen mode Exit fullscreen mode
You probably expect: 1, 2, 3, 4.
You get: 1, 4, 3, 2.
Nothing is broken. JavaScript is doing exactly what it was designed to do. The confusing part is that the language looks like it runs top to bottom, but it does not always work that way.
In this article you will learn three ideas that explain almost every async surprise in JavaScript:
- The call stack (what runs right now)
- The event loop (what runs next)
- Async code (how waiting fits in without freezing your app)
No prior knowledge of the event loop required. We will build the mental model step by step.
The Problem
What developers expect
Most of us learn JavaScript like this: the engine reads your file line by line and runs each line in order.
That mental model works fine for code like this:
function greet() {
console.log('hello');
}
greet();
console.log('bye');
Enter fullscreen mode Exit fullscreen mode
Output: hello, then bye. Easy.
Then we add setTimeout, Promises, and async/await. The same mental model breaks.
A common frustrating moment
You are debugging a feature. You sprinkle console.log calls to trace the flow. The logs come back in an order that makes no sense.
Or you build a search box. Each keystroke fires a fetch. Sometimes an older response overwrites a newer one. The bug feels random.
Or you use async/await thinking the UI will stay responsive, but a heavy loop after an await still freezes the page.
These are not separate mysteries. They all come from the same root cause: JavaScript can only run one piece of code at a time, but it still needs to handle waiting.
Why It Happens
Part 1: The call stack
Think of the call stack as JavaScript's to-do list for right now.
When your code calls a function, that function goes on top of the stack. When the function finishes, it comes off. The engine always works on whatever is on top.
function c() { console.log('c'); }
function b() { c(); }
function a() { b(); }
a();
Enter fullscreen mode Exit fullscreen mode
While c runs, the stack looks like this:
┌─────────┐
│ c() │ ← running now
├─────────┤
│ b() │
├─────────┤
│ a() │
└─────────┘
CALL STACK
Enter fullscreen mode Exit fullscreen mode
Rules of the call stack:
- Only one function runs at a time
- Synchronous code runs immediately, in call order
- When the stack is empty, the current "turn" of synchronous work is done
That last rule is the key to everything that follows.
Part 2: JavaScript cannot wait on the stack
Say you write this:
const data = fetch('/api/user'); // network request
console.log(data);
Enter fullscreen mode Exit fullscreen mode
If JavaScript actually stopped and waited for the network, your entire page would freeze. No clicks. No scrolling. No animations.
So the engine does something smarter. It starts the fetch, then moves on to the next line. When the network responds later, a callback runs.
That "run later" part is handled outside the call stack, by the event loop.
Part 3: The event loop in plain English
Part 1 showed what runs right now (the call stack).
Part 2 showed that fetch and setTimeout cannot block the stack while they wait.
So where does that waiting work go? And when does the "run later" code actually execute?
That is what the event loop handles.
A simple analogy
Imagine a cashier (the call stack) who can only help one customer at a time.
- Synchronous code walks up and gets served immediately.
-
setTimeoutandfetchare like customers who step aside and wait for a text message ("your order is ready"). - When the cashier has no one in line, they check two waiting lists before calling the next walk-in customer.
Those two waiting lists are the important part. JavaScript does not use one generic "later" queue. It uses two, with a strict priority order.
The two waiting lists
| Waiting list | Plain name | What goes here | Examples |
|---|---|---|---|
| Microtask queue | the Promise line | callbacks that should run soon |
.then(), await continuations, queueMicrotask()
|
| Macrotask queue | the timer/event line | callbacks that can wait a bit longer |
setTimeout, clicks, network I/O callbacks |
Think of it like airport boarding:
- Microtasks = priority boarding (Promises go first)
-
Macrotasks = general boarding (
setTimeout, UI events)
What the event loop does
The event loop is not a background thread. It is a repeating checklist the engine follows:
1. Run synchronous code until the call stack is empty
2. Run EVERY callback in the Promise line (microtasks)
3. Run ONE callback from the timer/event line (macrotasks)
4. Go back to step 2
Enter fullscreen mode Exit fullscreen mode
One sentence version: sync first, then all Promises, then one timer/event, then repeat.
Where setTimeout and fetch fit
When you call setTimeout or fetch, the engine does not run your callback immediately. It hands the waiting job to the browser (or Node.js). That happens outside the call stack.
Your JS code Browser / Node (outside the stack)
───────────── ──────────────────────────────────
setTimeout(fn, 0) ────────► timer starts...
fetch('/api') ────────► network request starts...
(your code keeps running)
...time passes...
timer fires ◄──────── puts fn in the timer/event line
response arrives ◄──────── puts .then callback in the Promise line
Enter fullscreen mode Exit fullscreen mode
Your callback only runs when the event loop picks it from a waiting list and puts it back on the call stack.
Walk through our opening example
Here is the snippet again:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
Enter fullscreen mode Exit fullscreen mode
Step 1: synchronous code (call stack)
| Line | What happens | Printed so far |
|---|---|---|
console.log('1') |
runs immediately | 1 |
setTimeout(...) |
browser starts a timer, callback waits in the timer/event line | 1 |
Promise.then(...) |
callback waits in the Promise line | 1 |
console.log('4') |
runs immediately |
1, 4
|
Call stack is now empty. Synchronous work is done.
Step 2: Promise line (microtasks)
The engine runs all Promise callbacks before touching timers:
| What runs | Printed so far |
|---|---|
console.log('3') |
1, 4, 3
|
Step 3: timer/event line (one macrotask)
Now the setTimeout callback gets its turn:
| What runs | Printed so far |
|---|---|
console.log('2') |
1, 4, 3, 2
|
Final answer: 1, 4, 3, 2
3 beats 2 because Promise callbacks always run before setTimeout callbacks, even when the timer is set to 0.
The one rule to remember
Sync code first. Then all Promises. Then one timer or event. Then repeat.
If you remember that, the opening example stops feeling random.
Part 4: What async/await actually does
async/await is just Promise syntax. It does not pause the entire program.
async function load() {
console.log('A');
await fetch('/api/user');
console.log('B');
}
console.log('1');
load();
console.log('2');
Enter fullscreen mode Exit fullscreen mode
What happens:
-
1prints (sync) -
load()starts,Aprints (sync) -
awaithits the network and steps out of the function -
2prints (sync) because the stack is free - Fetch finishes,
Bprints (microtask)
Output: 1, A, 2, B
The code after await does not run when the network finishes inside your function like a blocking pause. It gets scheduled as a microtask for later.
Manual Solution: Trace the Code by Hand
You do not need tools to predict small examples. Label each line, then follow the event loop rules.
Take our opening snippet:
console.log('1'); // SYNC
setTimeout(() => console.log('2'), 0); // schedules MACROTASK
Promise.resolve().then(() => console.log('3')); // schedules MICROTASK
console.log('4'); // SYNC
Enter fullscreen mode Exit fullscreen mode
Now walk through it.
Round 1: synchronous code
| Step | Call stack | Console output |
|---|---|---|
console.log('1') |
[log] |
1 |
setTimeout(...) |
[setTimeout] |
(schedules 2 for later) |
Promise.then(...) |
[then] |
(schedules 3 for later) |
console.log('4') |
[log] |
4 |
| Stack empty | [] |
Round 2: microtasks
The Promise callback runs:
| Console output |
|---|
3 |
Round 3: one macrotask
The setTimeout callback runs:
| Console output |
|---|
2 |
Final output: 1, 4, 3, 2
That is the full manual process. For any small snippet:
- Run every synchronous line until the stack is empty
- Run every microtask
- Run one macrotask
- Repeat from step 2
Verify in DevTools
Open Chrome DevTools, go to Sources, and set a breakpoint inside a .then() callback. When execution pauses, look at the Call Stack panel on the right. It shows exactly which functions are active.
For log-order bugs, numbered labels help:
console.log('[sync] user clicked');
Enter fullscreen mode Exit fullscreen mode
I use this in code reviews all the time. It is simple and it works.
Drawbacks of the Manual Process
Tracing by hand is great for interview questions and 5-line snippets. It gets painful in real apps.
- React, Vue, and other frameworks schedule work you cannot see
- Network responses arrive in unpredictable order
- One file may mix
setTimeout, Promises, andawaitin ways that are hard to track - It is easy to forget the rule: all microtasks run before the next macrotask
Knowing the rules is not the same as writing code that survives fast user input and slow networks. That is where patterns help.
Better Solution: Three Rules for Async Code
Instead of tracing every line in your head, write code that matches how the runtime works.
Rule 1: Keep synchronous blocks short.
Long loops on the main thread block everything, even after an await. If work is heavy, break it into chunks or move it to a Web Worker.
Rule 2: Use async/await to sequence steps in one flow.
When step B needs the result of step A, await is the right tool. It keeps the logic readable.
Rule 3: Guard against out-of-order async results.
When the user can trigger the same async action multiple times (search, pagination, tab switches), ignore or cancel stale results.
These three rules fix most production async bugs I have seen.
Complete Code
Here is a before/after for a search box. This is the full example we will break down.
Before (buggy):
searchInput.addEventListener('input', async (event) => {
const query = event.target.value;
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
renderResults(data); // may render stale data
});
Enter fullscreen mode Exit fullscreen mode
After (fixed):
let activeController = null;
searchInput.addEventListener('input', async (event) => {
const query = event.target.value;
// Cancel the previous in-flight request
if (activeController) {
activeController.abort();
}
activeController = new AbortController();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: activeController.signal }
);
const data = await response.json();
renderResults(data);
} catch (error) {
if (error.name === 'AbortError') {
return; // a newer search started, ignore this one
}
showError(error);
}
});
Enter fullscreen mode Exit fullscreen mode
Breakdown
Why the "before" code breaks
Each keystroke starts a fetch. All requests run at the same time. Whichever response arrives last wins, not whichever query is current.
If the user types react quickly, the response for re might arrive after react. The UI shows results for re. That looks like async code is "random." It is not. The event loop is working fine. The code just never checks whether the result is still relevant.
activeController
A variable that holds the current AbortController. Think of it as a remote control for the in-flight fetch.
When the user types a new character, we abort the old request before starting a new one.
AbortController and signal
fetch accepts a signal option. When you call controller.abort(), the in-flight fetch rejects with an AbortError.
This is better than ignoring stale results silently. It actually cancels work you no longer need.
The try/catch block
Not every aborted request is an error worth showing. AbortError means "a newer search replaced this one." We return early and do nothing.
Real errors (network down, 500 response) still go to showError.
Real Example: Step by Step
Let us trace the fixed search box when a user types r, then re.
Keystroke 1: user types r
SYNC: input handler runs
SYNC: abort() called (nothing to abort yet)
SYNC: fetch('/api/search?q=r') starts (network waits in background)
SYNC: handler returns, stack is empty
...later...
MICRO: await continuation runs, renderResults for "r"
Enter fullscreen mode Exit fullscreen mode
Keystroke 2: user types re (before r responds)
SYNC: input handler runs
SYNC: abort() cancels the "r" request
SYNC: fetch('/api/search?q=re') starts
SYNC: handler returns
MICRO: "r" fetch rejects with AbortError → catch block returns early
...later...
MICRO: "re" fetch resolves → renderResults for "re"
Enter fullscreen mode Exit fullscreen mode
The UI always shows results for the latest query. No race condition.
Back to the classic log-order example
Paste this in your console and watch it run:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
Enter fullscreen mode Exit fullscreen mode
| Phase | What runs | Output so far |
|---|---|---|
| Sync |
1, 4
|
1, 4
|
| Microtasks | Promise callback |
1, 4, 3
|
| Macrotask | setTimeout callback |
1, 4, 3, 2
|
Once you have done this two or three times, the order stops feeling random.
Warnings and Edge Cases
setTimeout(fn, 0) does not mean "run immediately after sync code"
It means "schedule a macrotask." Promise callbacks still run first. If you need to defer work to after the current synchronous block but before timers, use queueMicrotask() instead.
await does not run fetches in parallel
This is sequential (B waits for A):
const a = await fetch('/a');
const b = await fetch('/b');
Enter fullscreen mode Exit fullscreen mode
This runs both at once:
const [a, b] = await Promise.all([
fetch('/a'),
fetch('/b'),
]);
Enter fullscreen mode Exit fullscreen mode
Long sync work after await still blocks the UI
async function heavy() {
await fetch('/data');
for (let i = 0; i < 1_000_000_000; i++) {} // blocks clicks and scrolling
}
Enter fullscreen mode Exit fullscreen mode
await only yields before the loop, not during it.
Node.js has one extra queue
In Node, process.nextTick() runs even before microtasks. In browser code you will rarely need this, but it shows up in Node tutorials.
Frameworks add their own scheduling
React 18 batches state updates differently than React 17. If your logs inside useEffect do not match a bare-bones example, the framework may be batching work. Always test in your real setup.
When to Use This Knowledge
Reach for this mental model when:
- Console logs appear in a confusing order
- A fetch-heavy UI shows stale data
- You are choosing between
setTimeout,Promise, andqueueMicrotask - You are reviewing async code in a pull request
This alone is not enough when:
- You have CPU-heavy work (use a Web Worker)
- You need true multi-core parallelism
- You are doing server-side concurrency in Node (look into worker threads)
On a team, it helps to agree on one pattern for canceling stale fetches (AbortController is a good default) and to ask in review: "what happens if the user triggers this twice before the first call finishes?"
Conclusion
JavaScript runs synchronous code on the call stack first. When the stack is empty, the event loop runs microtasks (Promises, await), then one macrotask (setTimeout, events), and repeats.
That single rule explains why 1, 4, 3, 2 is the correct output for our opening example. It also explains why search boxes need cancellation logic, and why async/await alone does not keep the UI smooth during heavy loops.
You do not need to memorize every edge case. Remember the big picture: sync first, then microtasks, then one macrotask. Everything else is a variation on that.
What was the first async bug that made you stop and question how JavaScript actually works?



