
TL;DR
An
asyncfunction runs synchronously until its firstawait, then hands back a Promise.
awaitpauses the function and schedules "the rest of the function" to run as a microtask once the awaited promise settles.Under the hood, the function becomes a resumable state machine. No threads are involved.
The microtask queue explains almost every "weird" ordering bug you will ever meet.
Before we explain anything, predict the output of this snippet. Write your answer down; we will check it in section 6.
Before we explain anything, predict the output of this snippet. Write your answer down; we will check it in section 6.
console.log('1: sync start');
setTimeout(() => console.log('2: timeout'), 0);
(async () => {
console.log('3: inside async, before await');
await null;
console.log('4: after await');
})();
Promise.resolve().then(() => console.log('5: promise.then'));
console.log('6: sync end');If your answer was "1, 3, 6, 5, 4, 2" or "1, 3, 4, 6, 5, 2", this post is for you. By the end you will not just know the right order, you will be able to derive it for any snippet.
1. The problem async/await solves
JavaScript runs your code on one thread. If that thread waits for a network response, nothing else can happen: no clicks, no timers, no rendering. So waiting must never block. Instead, we start an operation, say what to do when it finishes, and get out of the way.
Three generations of syntax have expressed that idea:
// 2009: callbacks
getUser(id, (err, user) => {
if (err) return handle(err);
getPosts(user, (err, posts) => {
if (err) return handle(err);
render(posts);
});
});
// 2015 (ES2015): promises
getUser(id).then(getPosts).then(render).catch(handle);
// 2017 (ES2017): async/await (inside an async function or an ES module)
try {
const user = await getUser(id);
const posts = await getPosts(user);
render(posts);
} catch (err) {
handle(err);
}All three run on the same mechanism. Each new syntax is a thinner layer over the one below it:
flowchart TB
A["<b>async / await</b><br/>syntax that pauses and resumes a function"]
B["<b>Promises</b><br/>an object representing a value that arrives later"]
C["<b>Event loop + microtask queue</b><br/>decides which callback runs next"]
D["<b>Host runtime</b><br/>timers, network, disk (browser or libuv)"]
A -->|built on| B
B -->|built on| C
C -->|built on| DWe will build up from the bottom.
2. Layer 1: the event loop and its two queues
The runtime has one call stack and two queues that feed it:
Task queue (a.k.a. macrotasks): timer callbacks, I/O completions, UI events.
Microtask queue: promise reactions (
.then,.catch,.finally),awaitcontinuations, andqueueMicrotask()callbacks.
One turn of the event loop looks like this:
flowchart TD
A([Start of loop iteration]) --> B["Take ONE task from the task queue"]
B --> C["Run it until the call stack is empty"]
C --> D{"Microtask queue empty?"}
D -- No --> E["Run the next microtask"]
E --> D
D -- Yes --> F["Browser only: maybe render a frame"]
F --> AThe rule that matters most in this entire post:
After every task, the engine drains the entire microtask queue, including microtasks queued by other microtasks, before it takes the next task.
Think of a clerk at a counter. Each customer (a task) is served completely. Any follow-up paperwork that customer generates (microtasks) is finished before the next customer is called.
Node.js note: Node adds
process.nextTick, which has its own queue that runs just ahead of promise microtasks. The mental model is the same.
3. Layer 2: promises in 60 seconds
A promise is a small state machine with exactly three states, and it can leave pending only once:
stateDiagram-v2
[*] --> Pending
Pending --> Fulfilled: resolve(value)
Pending --> Rejected: reject(reason)
Fulfilled --> [*]
Rejected --> [*]The behavior that trips people up is that .then callbacks are never called synchronously, even if the promise is already fulfilled. They are always queued as microtasks:
const p = Promise.resolve(42);
p.then((v) => console.log('then', v));
console.log('sync');
// Output:
// sync
// then 42Hold on to that. await is built on exactly this behavior.
4. Layer 3: what async and await do
Two rules of async functions
It always returns a promise. A returned value fulfills it, and a thrown error rejects it.
The body runs synchronously until the first
await. Nothing is deferred just because the function is markedasync.
async function demo() {
console.log('A');
await somethingAsync();
console.log('B');
}
console.log('before');
demo();
console.log('after');
// before
// A <- ran synchronously, inside the call to demo()
// after
// B <- ran later, as a microtask continuationWhat await does
When execution reaches await expr, the engine:
Evaluates
exprand wraps the result in a promise (Promise.resolve(expr)).Suspends the function, saving its local variables and its position.
Returns to the caller, which now holds a pending promise and keeps running.
Subscribes "resume me here" to the awaited promise.
When that promise settles, a microtask resumes the function with the result (or throws the error at the
awaitsite).
sequenceDiagram
participant Caller
participant Fn as async function
participant Net as Network
participant MQ as Microtask queue
Caller->>Fn: getUserName(1)
Fn->>Net: fetch() starts
Fn-->>Caller: await: suspend and return a pending Promise
Note over Caller: Caller keeps running synchronously
Net-->>MQ: response arrives, promise fulfills, continuation queued
MQ->>Fn: run continuation
Fn->>Fn: resume right after the await
Fn-->>Caller: outer promise fulfills with the resultFor simple, linear code, you can think of await as .then with the rest of the function as the callback:
async function getUserName(id) {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
return user.name;
}
// Roughly equivalent to:
function getUserName(id) {
return fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((user) => user.name);
}But that equivalence breaks down quickly. What about loops containing await? try/catch around an await? A variable declared before the first await and used after the second? A plain .then chain cannot express these without a lot of manual plumbing. Something has to remember the function's state while it is paused.
5. What the engine really does: suspend, save, resume
Pausing a function means:
Copy its local variables and "current position" off the call stack into a heap object.
Pop the function off the stack so the caller can continue.
Later, restore that object and jump back to the saved position.
JavaScript already had a feature that does exactly this: generators. Before native async/await, libraries like co and Babel's regenerator implemented it by driving a generator with promises:
function run(generatorFn) {
return new Promise((resolve, reject) => {
const gen = generatorFn();
function step(method, arg) {
let result;
try {
result = gen[method](arg); // resume the generator
} catch (err) {
return reject(err); // generator threw: reject outer promise
}
if (result.done) return resolve(result.value); // "return" statement
Promise.resolve(result.value).then(
(value) => step('next', value), // fulfilled: resume with the value
(err) => step('throw', err) // rejected: throw INTO the function
);
}
step('next');
});
}
// An async function is a generator where `await` is `yield`:
function getUserName(id) {
return run(function* () {
const res = yield fetch(`/api/users/${id}`);
const user = yield res.json();
return user.name;
});
}Notice the step('throw', err) branch. It throws the rejection back into the paused function at the exact yield, which is why an ordinary try/catch around an await works.
Native engines skip the userland driver, but the idea is the same. V8, for example, reuses the same suspend/resume machinery for generators and async functions.
Compilers that target languages without generators (C#, Rust, Kotlin) go one step further and rewrite your function into an explicit state machine. Here is a hand-written version of getUserName:
function getUserName(id) {
// Locals that must survive across awaits live here (the "saved frame").
let state = 0;
let res, user;
return new Promise((resolve, reject) => {
function resume(value) {
try {
switch (state) {
case 0: // run until the first await
state = 1;
Promise.resolve(fetch(`/api/users/${id}`)).then(resume, reject);
return; // "await" = give up the thread
case 1: // resumed with the fetch result
res = value;
state = 2;
Promise.resolve(res.json()).then(resume, reject);
return;
case 2: // resumed with the parsed JSON
user = value;
resolve(user.name); // "return" = settle outer promise
return;
}
} catch (err) {
reject(err);
}
}
resume(); // runs synchronously until the first await, like a real async function
});
}Simplification: this version rejects the outer promise directly on failure. A real implementation throws the error back into the function at the suspension point, so your own
try/catchblocks can handle it, as the generator driver above does.
As a diagram, the compiler turned your three-line function into this:
stateDiagram-v2
[*] --> S0
S0 : State 0 - start
S1 : State 1 - resumed with res
S2 : State 2 - resumed with user
S0 --> S1: fetch issued, first await
S1 --> S2: res.json issued, second await
S2 --> [*]: resolve outer promise with user.nameEach await is an edge between states. Between those edges, your function is just an object sitting on the heap, costing no thread and no stack frame.
6. Solving the puzzle
Back to the opening snippet. Trace it phase by phase.
Phase 1: the synchronous run (the current task)
Step | What happens | Microtask queue | Task queue |
|---|---|---|---|
1 |
| empty | empty |
2 |
| empty | empty |
3 | The async IIFE starts and prints 3 (sync until first await) | empty | empty |
4 |
|
| empty |
5 |
|
| empty |
6 |
| same | empty |
Phase 2: drain all microtasks, in order
Resume the async function: prints 4.
Run the
.thencallback: prints 5.
Phase 3: next task
The timer has expired by now, so the host queues its callback as a task. The loop picks it up and prints 2.
Final output: 1, 3, 6, 4, 5, 2.
Two facts did all the work: async functions run synchronously until the first await, and microtasks always run before the next task, even a setTimeout(..., 0).
7. How many ticks does await cost?
In modern engines, awaiting a native promise or a plain value costs one microtask tick. Before a spec change that landed in V8 7.2 and Node 12, it cost three, which is why old blog posts quote confusing orderings.
Returning a promise from an async function is more expensive. Resolving a promise with another promise needs extra jobs to unwrap it, adding two extra ticks:
async function returnsValue() {
return 1;
}
async function returnsPromise() {
return Promise.resolve(1);
}
returnsPromise().then(() => console.log('returnsPromise'));
returnsValue().then(() => console.log('returnsValue'));
// Output:
// returnsValue <- called SECOND, but finishes first
// returnsPromiseYou should never write code that depends on exact tick counts. But knowing they exist saves hours when a log line appears "too early" or "too late."
8. Async is not parallel
This is the most expensive misconception in the async world. await does not run anything on another thread. It only lets other JavaScript run while the host does the waiting. If you hog the thread, everything stalls:
setTimeout(() => console.log('timer fired'), 100);
async function crunch() {
const start = Date.now();
while (Date.now() - start < 800) {} // stand-in for a huge JSON.parse or a hot loop
return 'done';
}
crunch().then(console.log);
// 'done' prints first, then 'timer fired' at ~800 ms instead of ~100 mssequenceDiagram
participant T as Timer 100 ms
participant L as Event loop
participant J as Your JS
L->>J: run task: heavy loop (800 ms)
Note over J: Nothing else can run. No timers, no promises, no clicks.
T-->>L: 100 ms elapsed, callback queued
J-->>L: loop finishes after 800 ms
L->>J: run timer callback, 700 ms lateFor CPU-bound work, reach for Web Workers or worker_threads, or split the work into chunks that yield back to the event loop.
9. Five pitfalls that follow from the model
Pitfall 1: accidental serial awaits
// Slow: each request waits for the previous one (~900 ms for three 300 ms calls)
const results = [];
for (const id of ids) {
results.push(await fetchUser(id));
}
// Fast: all requests in flight at once (~300 ms)
const results = await Promise.all(ids.map(fetchUser));sequenceDiagram
participant App
participant A as Service A
participant B as Service B
participant C as Service C
Note over App,C: Promise.all - all three in flight, about 300 ms total
par
App->>A: request
and
App->>B: request
and
App->>C: request
end
A-->>App: response
B-->>App: response
C-->>App: responseUnbounded
Promise.allover thousands of items can overwhelm an API or exhaust sockets. Use a concurrency limiter such asp-limit, or process in batches.
Pitfall 2: forEach does not wait
items.forEach(async (item) => {
await save(item);
});
console.log('done'); // prints BEFORE any save finishes
// Fix:
await Promise.all(items.map(save)); // parallel
for (const item of items) await save(item); // sequentialforEach ignores the promises returned by your callback, so there is nothing for it to wait on.
Pitfall 3: the floating promise
async function handler() {
save(item); // forgot await: errors become unhandled rejections
}In Node 15+, an unhandled rejection crashes the process by default. Enable a lint rule such as @typescript-eslint/no-floating-promises.
Pitfall 4: return vs return await inside try
async function a() {
try {
return fetchData(); // the promise is returned, NOT awaited here
} catch (e) {
return fallback(); // never runs: the rejection escapes the try block
}
}
async function b() {
try {
return await fetchData(); // awaited inside the try, so the catch works
} catch (e) {
return fallback();
}
}Pitfall 5: microtask starvation
Because the microtask queue is drained completely before the next task, a self-perpetuating microtask loop freezes the page:
function loop() {
Promise.resolve().then(loop);
}
loop(); // timers, clicks and rendering never get a turn10. The same idea in other languages
The details differ, but the central trick (compile a function into a resumable state machine and let a scheduler resume it) shows up everywhere:
JavaScript | Python ( | Rust | C# | |
|---|---|---|---|---|
Calling an async function gives you | A Promise, already running | A coroutine object, not running until awaited or scheduled | A | A |
Compiled to | Suspend/resume machinery in the engine | Generator-like coroutine | Compiler-generated state machine | Compiler-generated state machine ( |
Who resumes it | Built-in event loop | The | An executor you pick (Tokio, async-std, ...) | Thread pool or |
Threads | One (plus workers) | One loop thread | Often a multi-threaded executor | Multi-threaded |
Rust makes the machinery most visible. A future is polled by an executor, and it returns Pending after telling the OS how to wake it:
sequenceDiagram
participant Ex as Executor
participant F as Future (state machine)
participant IO as OS reactor
Ex->>F: poll()
F->>IO: register interest and a waker
F-->>Ex: Poll::Pending
Note over Ex: Executor runs other tasks meanwhile
IO-->>Ex: waker.wake()
Ex->>F: poll() again
F-->>Ex: Poll::Ready(value)Same shape as our JavaScript diagram: suspend, subscribe, get woken, resume. The big practical difference is eager vs. lazy. A JavaScript promise starts working the instant you create it. A Rust future or Python coroutine does nothing until something drives it.
11. The mental model on one screen
asyncfunction: runs synchronously until its firstawait, and always returns a promise.await: suspend the function, return to the caller, and queue the continuation as a microtask when the awaited promise settles.Microtasks drain completely after every task and before the next task (timers, I/O, rendering).
Pausing is cheap because the function becomes a heap-allocated state machine. No thread is blocked.
Concurrency is not parallelism.
awaitoverlaps waiting, not computing.
Exercises
Puzzle A. What does this print?
async function a() {
console.log('a1');
await b();
console.log('a2');
}
async function b() {
console.log('b');
}
console.log('start');
a();
new Promise((resolve) => {
console.log('p');
resolve();
}).then(() => console.log('then'));
console.log('end');Answer
start, a1, b, p, end, a2, then
a() runs synchronously through a1 and into b(), which prints b and returns an already-resolved promise. await queues the continuation of a first. The promise executor prints p synchronously and queues then second. After end, the microtasks run in queue order: a2, then then.
Puzzle B. What does this print?
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => {
console.log('micro 1');
Promise.resolve().then(() => console.log('micro 2'));
});
queueMicrotask(() => console.log('micro 3'));Answer
micro 1, micro 3, micro 2, timeout
micro 1 and micro 3 are queued during the synchronous run. While micro 1 runs, it queues micro 2 at the back of the queue, behind micro 3. The queue is drained completely, including newly added jobs, before the timer task gets a turn.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!