What will this code print?
console.log('A');
setTimeout(() => console.log('D'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('B');
The output is:
A
B
C
D
D does not run immediately, even with a zero-millisecond timer. More surprisingly, C runs before it. Predicting this order requires more than the familiar picture of a call stack and one callback queue. In a browser, tasks and microtasks have different scheduling rules.
This article uses the browser's event loop as its primary model. The examples can be pasted into a browser console. Node.js shares some of these behaviors, but its event loop is not identical.
A useful model: finish the task, drain microtasks, then move on
The call stack tracks the functions currently executing. JavaScript runs the current synchronous work to completion on that thread; a queued callback does not interrupt a function halfway through.
The browser coordinates this work using an event loop. For the examples here, think in terms of three steps:
- Run a task. Evaluating a script, dispatching many browser events, and running a timer callback are examples of task-related work.
- Drain the microtask queue. Promise reactions and callbacks scheduled with
queueMicrotask()run when the current JavaScript work has completed. If a microtask schedules another microtask, that new one also runs before the browser moves on. - Consider rendering, then choose more work. The browser may update the page before a later task. It does not promise to paint after every task or microtask checkpoint.
This is a teaching model, not a claim that the HTML standard defines one universal FIFO task queue. Browsers can choose among task queues and task sources. The critical ordering rule for our examples is that the current work finishes and the microtask queue is emptied before the next timer task executes. See the HTML Standard's event loop algorithms and MDN's microtask guide.
Now trace the opening example:
| Step | What happens | Printed |
|---|---|---|
| 1 | The script runs console.log('A'). | A |
| 2 | setTimeout schedules a callback for a later task, after its timer becomes eligible. | Nothing |
| 3 | Promise.resolve().then(...) queues a promise reaction as a microtask. | Nothing |
| 4 | The script logs B and finishes. | B |
| 5 | The microtask checkpoint runs the promise reaction. | C |
| 6 | A later event-loop turn can run the timer callback. | D |
A delay of 0 is not a guarantee of immediate execution or an exact elapsed time. Browser timer minimums, background throttling, and other work can delay callbacks. The separate guide to why browsers throttle JavaScript timers covers those timing constraints.
Microtasks can create more microtasks
The distinction becomes clearer when one microtask queues another:
console.log('start');
setTimeout(() => console.log('timer'), 0);
queueMicrotask(() => {
console.log('microtask 1');
queueMicrotask(() => console.log('microtask 3'));
});
Promise.resolve().then(() => console.log('microtask 2'));
console.log('end');
Output:
start
end
microtask 1
microtask 2
microtask 3
timer
First the script prints start and end. At that point the microtask queue contains microtask 1 followed by microtask 2. Running the first callback appends microtask 3 to the queue's end, so the order is 1, 2, 3. Only after the queue is empty can the timer callback run.
This gives you a practical tracing method: write down each queued microtask in the order it is added, including microtasks scheduled by other microtasks. Do not assume that scheduling a callback immediately runs it.
It also reveals a failure mode. A microtask that continuously queues another microtask can prevent the event loop from reaching later tasks and rendering opportunities. Microtasks are for small follow-up work, not a way to make a large calculation non-blocking.
What actually happens at an await?
async functions do not automatically run in the background. Code before their first await starts synchronously when the function is called.
async function load() {
console.log('inside before await');
await Promise.resolve('ok');
console.log('inside after await');
}
console.log('outside before');
load();
console.log('outside after');
Output:
outside before
inside before await
outside after
inside after await
Calling load() begins running its body immediately. At await, execution of that async function is suspended. The caller proceeds and logs outside after. Even though the promise is already fulfilled, the rest of load() resumes asynchronously as a microtask.
For a pending network request, the resumed code runs only after the promise settles. The network does not become synchronous just because its result is used with await. MDN documents this behavior in its await reference.
Remember the scope of the pause: await pauses the surrounding async function, not every script on the page. And CPU-heavy JavaScript after the await still occupies the main thread when it eventually runs.
Why a resolved promise does not guarantee a screen update
A common UI mistake is assuming that any await gives the browser time to paint:
<button id="run">Run</button>
<p id="status">Idle</p>
<script>
document.querySelector('#run').addEventListener('click', async () => {
const status = document.querySelector('#status');
status.textContent = 'Working...';
await Promise.resolve();
status.textContent = 'Done';
});
</script>
The DOM changes to Working..., then to Done. The browser is not guaranteed to paint the intermediate text. The fulfilled promise resumes at a microtask checkpoint, before the browser gets a normal opportunity to render, so the two DOM changes can appear as just the final state.
That matters when a loading indicator is supposed to be visible while expensive work happens. Replacing synchronous work with a promise callback does not move the calculation off the main thread. For expensive computation, consider dividing work into appropriately scheduled tasks or moving it to a Web Worker; each option has tradeoffs. For animation work, requestAnimationFrame() schedules a callback before a repaint, but running heavy work inside that callback can still delay the frame. See MDN's in-depth event loop explanation and requestAnimationFrame() reference.
How to debug an unexpected execution order
When console output surprises you, trace one callback at a time:
- Identify the environment. Is this browser code or Node.js? The surrounding event loop matters.
- Mark synchronous execution first. Nothing queued can interrupt the currently running JavaScript.
- Classify each scheduled callback. A
.then()handler orqueueMicrotask()callback is a microtask; a browser timer callback runs as a later task. - Drain newly added microtasks too. Do not move to the next task until the microtask queue is empty.
- Separate execution order from elapsed time and painting. A timer threshold is not a deadline; a DOM mutation is not proof that a frame appeared on screen.
For code reacting to user events, the callback's execution time is only one part of the picture. The separate article on event delegation and dynamic UIs covers which element handles a click and why nested targets matter.
The Node.js boundary
Do not apply the browser diagram unchanged to Node.js. Node has event-loop phases for timers, I/O polling, setImmediate(), and other callbacks. It also provides process.nextTick(), which has special scheduling behavior and is not a browser API. In particular, the relative order of setTimeout(fn, 0) and setImmediate(fn) can depend on their context.
The Node.js event loop documentation describes those phases and version-dependent details. The three examples above deliberately avoid Node-specific APIs so the common promise-versus-timer lesson stays clear.
If you remember one rule, use this one: finish the current synchronous work, process microtasks until they are exhausted, and only then reason about the next eligible browser task. That explains the A, B, C, D result and provides a reliable starting point for investigating real timing bugs.
