Promises, Async/Await, and the Event Loop
I explain the browser's event loop, the difference between tasks and microtasks, and how promises and async/await schedule their work on those queues.

Here’s a four-line quiz that humbles almost everyone, including people who write JavaScript every day:
console.log("first");
setTimeout(() => console.log("fourth"), 0);
Promise.resolve().then(() => console.log("second"));
The output is first, second, fourth — and the interesting part is that fourth always loses to second, even though setTimeout got a 0 millisecond delay and was registered earlier. The timer isn’t slow. It’s just standing in a different line.
If you can’t say precisely which line, callbacks, promises, and await stay a guessing game — and every race condition you’ll meet in browser code traces back to it. This post builds the browser’s scheduling model from the ground up: the event loop, the two queues it serves, and how promises and async/await ride them. (The call-stack half of this picture came from JavaScript Execution Contexts Explained; here we add time to that model.)
Series: JavaScript deep-dive, part 3. Previous: Understanding Closures in JavaScript with Real Examples.
On this page
- One thread, and it is never allowed to block
- Two queues: tasks and microtasks
- The order test, step by step
- What a promise actually guarantees
- async/await is the same machinery with nicer syntax
- When the loop gets jammed
- Think in turns, not lines
One thread, and it is never allowed to block
JavaScript runs on a single thread. MDN’s execution model page puts the design constraint bluntly: the nature of JavaScript as a web scripting language requires it to be never blocking. One thread, and it may never stand still waiting for a network response or a timer, because everything else — other scripts, clicks, rendering — shares that same thread.
So JavaScript splits work into pieces that each run to completion. The runtime maintains queues of pending work; the loop’s job is to pull one piece, run it entirely, pull the next. That pulling mechanism is the event loop, and each piece of work is what the spec calls a job — in HTML terms, split into two categories that matter enormously.
Two queues: tasks and microtasks
The two queues have different rules and different members, and MDN documents both.
Tasks are the coarse work: the initial run of a script, handling a dispatched event (a click, an input), a timer or interval firing. Tasks come from the standard scheduling mechanisms and wait in the task queue.
Microtasks are short functions that run after the function or program that created them exits — only once the JavaScript stack is empty, and before control returns to the event loop. Promise callbacks live here, as does queueMicrotask(). The microtask queue isn’t served one-at-a-time: the entire microtask queue is drained before the next task is pulled.
That one rule — drain microtasks completely, then take one task — explains almost every ordering surprise in browser JavaScript:
| API | Which queue |
|---|---|
setTimeout / setInterval callback |
task |
Promise .then / .catch / .finally callback |
microtask |
await continuation |
microtask |
queueMicrotask callback |
microtask |
Event listeners (click, input, …) |
task |
| Initial script execution | task |
Run-to-completion glues it together: each job is processed completely before any other job runs, so nothing can preempt your function halfway through. The upside is sanity — no other code mutates your data mid-function. The downside is the flip side, and it gets its own section.
The order test, step by step
Back to the quiz, now with the machinery visible.
console.log("first");
setTimeout(() => console.log("fourth"), 0);
Promise.resolve().then(() => console.log("second"));
queueMicrotask(() => console.log("third"));
The output is first, second, third, fourth. Here’s the play-by-play:
- The script itself is a task. It runs top to bottom:
firstlogs synchronously. setTimeoutschedules its callback as a task. The 0 ms is a delay lower bound, not a promise of immediacy — the callback waits in the task queue.- The promise is already resolved, so
.thenqueuessecondas a microtask. So doesqueueMicrotaskwiththird. - The script ends. The stack is empty. Before the loop picks any new task, it drains the microtask queue:
secondruns, thenthird, in registration order. - Only now does the loop pull the oldest task:
fourthfinally logs.
No timing tricks, no randomness — the order is fully determined by which queue each callback lands in. The same two-.then example on MDN’s page shows the other half of the guarantee: predictable ordering within the microtask queue itself, registration order, no race.
Try this
- Paste the order test into a browser console and predict all four lines before pressing Enter.
- Add a second
setTimeoutbefore thePromise.resolve().thenline and predict where it lands.- Put a
console.log("zeroth")on the last line of the script.Expected result:
zerothprints with the synchronous code (beforesecond), because the script runs to completion before any queued work. BothsetTimeoutcallbacks still print after both microtasks — no matter where in the script they were registered.
What a promise actually guarantees
With scheduling settled, promises themselves get simple. A promise is a returned object you attach callbacks to, instead of passing callbacks into a function. It’s a placeholder for a value that isn’t ready yet, and it settles exactly once: fulfilled with a value, or rejected with a reason.
Two properties do the heavy lifting.
The executor runs synchronously. The function you pass to new Promise executes immediately, during construction — the constructor runs the executor for you. What becomes asynchronous is the reaction, not the setup:
const order = new Promise((resolve) => {
console.log("runs right now, synchronously");
resolve(42);
});
order
.then((value) => value + 1)
.then((value) => console.log(value)); // 43, later — microtask time
Callbacks are queued as microtasks, never called in-place. Even for an already-resolved promise, .then callbacks never run during the .then call — they run when the stack empties. That’s the fix for the old callback problem MDN calls the “state of Zalgo”: callback APIs that sometimes call you synchronously and sometimes not. Promises are async. Always. That consistency is the feature.
Chaining falls out of the object model: .then returns a new promise, so handlers form a pipeline where each stage transforms (or fails) the value, and one .catch at the end handles anything thrown anywhere above it.
async/await is the same machinery with nicer syntax
async/await doesn’t add a new concurrency mechanism. It re-expresses promise chains as statements. An async function always returns a promise, and using await pauses the function until its promise settles, resuming with the fulfillment value — or throwing the rejection reason.
Same flow, two eras of syntax:
// then-chains
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => render(posts))
.catch(reportError);
// async/await
async function showPosts() {
try {
const user = await getUser(1);
const posts = await getPosts(user.id);
render(posts);
} catch (err) {
reportError(err);
}
}
The detail worth internalising: an async function runs synchronously until its first await. At that point the function suspends — its context leaves the stack, exactly like the call-stack post described — and the rest of the function becomes a continuation scheduled on the microtask queue when the awaited promise settles. Every await line is a “pause here, resume later” marker, and “later” means microtask time.
Notice what each await callback carries with it: the function’s variables. That’s a closure — the mechanism from part 2 — doing the remembering. Async code is closures plus queues, nothing more mystical than that.
When the loop gets jammed
Run-to-completion has a price you feel in every frozen page. Because each job runs to completion, and because there’s one thread, a long-running job blocks everything: input events, timers, and the rendering work the browser would do between tasks — the same rendering pipeline from How the Web Works. A five-second synchronous computation is a five-second page freeze where clicks do nothing and nothing paints.
JavaScript has exactly one way out of a jam: get small. Break long work into pieces and schedule the continuation — setTimeout for coarse yielding, or microtask-aware patterns when ordering matters. The loop can only rotate through jobs if jobs actually end.
One honest boundary for this post: everything above describes the browser’s event loop. The Node.js loop looks similar from this distance but is built differently — its phases and library machinery deserve their own treatment, and comparing them carelessly is how people learn half a model. That comparison is a post for later in this series’ backend chapters.
Think in turns, not lines
The model that makes async JavaScript legible: your program runs in turns. A turn starts when the loop picks a job, and everything synchronous you wrote — plus every microtask queued along the way — finishes before the next turn begins. Promises are turn-aware value holders; await is a turn boundary you can see in the syntax.
So when ordering surprises you next, don’t reach for random explanations. Ask three questions: was this callback a task or a microtask? Which queue holds it? What else is in line before it? Nine times out of ten, the mystery dissolves into the queue you forgot existed. And when you want to go one layer deeper — what the engine, not the browser, is doing underneath all this — that’s the direction the series heads next.