A plain-English deep dive into what really happens when you await
— from Promises and microtasks to desugaring and generator runners.

await
— from Promises and microtasks to desugaring and generator runners.Introduction
async
/await
makes async code feel synchronous — which is awesome until something “runs later than it should,” errors vanish, or a loop takes forever because you accidentally serialized network calls. Under the hood there’s zero magic: the runtime is doing Promise juggling, microtask scheduling, and state-machine bookkeeping for you.
This article is a developer-to-developer tour of how it actually works. We’ll build mental models, peek at desugared code, write a mini generator runner, and cover the event loop, error propagation, top-level await, and the gotchas that bite real projects. Copy-paste snippets included.
1) The 10-second mental model
- An
async function
always returns aPromise
. await expr
is equivalent to:
- evaluate
expr
, - convert it to a Promise via
Promise.resolve(expr)
, - pause this function,
- resume later with the fulfilled value (or throw on rejection).
- While paused, JS can run other work. When the awaited promise settles, the function’s continuation runs in a microtask.
Keep that in your head and most “why did this run later?” mysteries disappear.
2) What an async function
really returns
async function getNumber() {
return 42;
}
const p = getNumber();
console.log(p instanceof Promise); // true
p.then(v => console.log(v)); // 42
Even though you returned a number, callers receive a Promise that fulfills with that number. If you throw
inside an async function, callers receive a rejected Promise.
async function boom() { throw new Error('nope'); }
boom().catch(e => console.log('Caught:', e.message)); // Caught: nope
Takeaway:
return X
→Promise.resolve(X)
.
throw E
→Promise.reject(E)
.
3) What await
actually does (step by step)
async function demo() {
console.log('A');
const v = await 1; // ← Promise.resolve(1)
console.log('B', v);
}
console.log('start');
demo();
console.log('end');
Order:
start
A
end
B 1
Why?
await 1
becomesawait Promise.resolve(1)
.- The async function pauses, returning control to the caller (“end” prints).
- The resolved value schedules the continuation on the microtask queue, which runs right after the current stack clears.
Rule:
await
doesn’t block the thread; it yields and resumes on a microtask.
4) Desugaring async/await → Promises (no generators)
Under the hood, this:
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
Reads like this with explicit Promises:
function fetchUser(id) {
return fetch(`/api/users/${id}`).then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
});
}
And try/catch
maps to .catch
:
async function main() {
try {
const user = await fetchUser(123);
console.log(user);
} catch (e) {
console.error('Failed:', e);
}
}
// ~>
function main() {
return fetchUser(123)
.then(user => { console.log(user); })
.catch(e => { console.error('Failed:', e); });
}
Key point: Async/await is syntax sugar for Promise chains with clean linear control flow and exceptions.
5) Desugaring async/await → Generators (how transpilers do it)
Older browsers didn’t have native async functions, so tools like Babel transpiled to generators plus a tiny runtime. Conceptually:
// Source
async function sum() {
const a = await fetchA();
const b = await fetchB();
return a + b;
}
Becomes something like:
function sum() {
return run(function* () {
const a = yield fetchA();
const b = yield fetchB();
return a + b;
});
}
Where run
is a miniature “co” runner:
function run(genFn) {
const it = genFn();
return new Promise((resolve, reject) => {
function step(nextF, arg) {
let result;
try { result = nextF.call(it, arg); }
catch (e) { return reject(e); }
const { value, done } = result;
if (done) return resolve(value);
// adopt thenables
Promise.resolve(value).then(
v => step(it.next, v),
e => step(it.throw, e)
);
}
step(it.next); // start
});
}
This runner shows the core: pause with yield
, resume on fulfillment, and throw into the generator on rejection — exactly what await
/try
/catch
do.
6) Error semantics (real try/catch/finally
)
Because the compiler turns your code into a state machine, try/catch/finally
works exactly like synchronous code — just time-shifted.
async function work() {
try {
await step1();
await step2(); // if this rejects...
} catch (e) {
await report(e); // ...control jumps here
} finally {
await cleanup(); // always runs, even if catch rethrows
}
}
- A rejection from
await
is thrown at that point. finally
runs even across awaits — the runtime schedules it appropriately.
Pitfall: If you forget
await
insidetry
, the rejection escapes thetry/catch
because you returned a promise you didn’t await (see Gotchas).
7) Microtasks, ordering, and “why did that log later?”
Microtasks (Promise reactions) run after the current stack, before the next macrotask (setTimeout
, I/O, UI events).
console.log('A');
setTimeout(() => console.log('B (task)'), 0);
(async () => {
console.log('C');
await null; // microtask checkpoint
console.log('D (after microtask)');
})();
console.log('E');
// A, C, E, D (microtask), B (macrotask)
React batching, Node I/O callbacks, and many framework internals lean on this ordering. If something “should be immediate,” make it a microtask:
queueMicrotask(() => { /* runs before next task */ });
8) Sequential vs parallel awaits (and a fast pattern)
This is sequential:
const a = await fetchA(); // waits
const b = await fetchB(); // waits again
This is parallel:
const [a, b] = await Promise.all([fetchA(), fetchB()]);
Rule of thumb: Start promises first,
await
them together.
Controlled parallelism (limit concurrency):
function limit(fn, pool = 5) {
let active = 0, q = [];
const run = (...args) => new Promise((resolve, reject) => {
const task = async () => {
active++;
try { resolve(await fn(...args)); }
catch (e) { reject(e); }
finally {
active--;
if (q.length) q.shift()();
}
};
active < pool ? task() : q.push(task);
});
return run;
}
9) Top-level await (modules)
In ES modules, you can await
at the top level:
// data.mjs
export const data = await fetch('/api/data').then(r => r.json());
- Module evaluation pauses until the awaited promise settles.
- Importers automatically wait; cycles are handled by the module loader.
Note: Scripts (
<script>
withouttype="module">
) don’t allow top-level await.
10) Unhandled rejections and safe patterns
An async function that throws maps to a rejected promise. If no one observes it:
- Browsers log “Uncaught (in promise)”.
- Node emits an
unhandledRejection
(fatal in some configs).
Always handle or surface:
// Option 1: return/await the promise
await doThing();
// Option 2: explicitly ignore (document it)
void doFireAndForget().catch(console.error); // log if it explodes
// Option 3: centralized .catch()
doThing().catch(reportError);
Tip: In event handlers,
async
callbacks return promises that aren’t awaited by the event system. Add your own.catch
.
11) Cancellation & timeouts (production must-haves)
Promises don’t have built-in cancellation; use AbortController and timeouts.
// Timeout wrapper
const withTimeout = (p, ms, msg = 'Timeout') =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(msg)), ms);
p.then(v => { clearTimeout(t); res(v); },
e => { clearTimeout(t); rej(e); });
});
// Cancellation with fetch
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 3000);
await fetch(url, { signal: ctrl.signal });
Retries with backoff:
async function retry(fn, { retries = 3, base = 200, factor = 2 } = {}) {
let attempt = 0, last;
while (attempt++ <= retries) {
try { return await fn(); }
catch (e) {
last = e;
if (attempt > retries) break;
await new Promise(r => setTimeout(r, base * factor ** (attempt - 1)));
}
}
throw last;
}
12) Performance notes (what really matters)
await
yields → gives the event loop room to render/UI/I-O.- Microtasks flush fully before tasks → a long microtask chain can starve timers; don’t spin unbounded microtasks.
- Stack traces: modern engines preserve async stack traces, but excessive Promise depth can still be noisy — prefer linear
await
. - CPU-bound work still blocks; move it to Web Workers / Node Worker Threads.
await
won’t make CPU faster.
13) Common gotchas (and fixes)
13.1 Forgetting await
inside try/catch
try {
doAsync(); // ❌ not awaited; rejection escapes
} catch (e) { /* never runs */ }
Fix: await doAsync()
or return the promise to a caller that handles it.
13.2 forEach
+ await
doesn’t do what you think
items.forEach(async item => {
await save(item); // not awaited by forEach
});
Fix: Use for...of
or Promise.all
:
for (const item of items) await save(item);
// or
await Promise.all(items.map(save));
13.3 Accidental serialization
for (const u of urls) {
await fetch(u); // sequential; often unintended
}
Fix: Start then await together:
await Promise.all(urls.map(u => fetch(u)));
13.4 Swallowing errors in finally
try { await doA(); }
finally { await doB(); } // if doB throws, you may hide doA's error
Fix: Capture and rethrow both with context if needed.
13.5 Fire-and-forget without logging
async function handler() {
doSideEffect(); // ❌ if rejects, it’s unobserved
}
Fix: void doSideEffect().catch(reportError);
13.6 Mixing callbacks & promises
Don’t wrap callback APIs incorrectly:
// ❌ resolve/reject called multiple times?
new Promise(res => fs.readFile(p, (e, d) => res(d))); // ignores errors
Fix: promisify correctly:
const p = (fn) => (...args) => new Promise((res, rej) =>
fn(...args, (e, v) => e ? rej(e) : res(v)));
14) Mini “how it works” state machine (ASCII)
[Start] --eval--> value
| |
| value is Promise/thenable?
| |
| yes / Promise.resolve(value)
v v
await? ------------> [Pause async fn]
|
(microtask)
v
[Resume with]
[fulfillment|throw]
|
next await...
Each await
splits your function into before and after states; the runtime stitches them with microtasks.
15) Testing async/await like a pro
- Use fake timers for scheduler-heavy code (Jest/Vitest).
- Always return/await the promise from tests:
it('works', async () => {
await expect(doThing()).resolves.toEqual(...);
});
- Assert rejections with
rejects
:
await expect(boom()).rejects.toThrow('nope');
- Flush microtasks when needed:
await Promise.resolve(); // one tick
16) Copy-paste utilities you’ll reuse
export const withTimeout = (p, ms, msg = 'Timeout') =>
new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error(msg)), ms);
p.then(v => { clearTimeout(t); res(v); },
e => { clearTimeout(t); rej(e); });
});
export const retry = async (fn, { retries = 3, base = 200, factor = 2 } = {}) => {
let attempt = 0, last;
while (attempt++ <= retries) {
try { return await fn(); }
catch (e) {
last = e;
if (attempt > retries) break;
await new Promise(r => setTimeout(r, base * factor ** (attempt - 1)));
}
}
throw last;
};
export const limit = (fn, pool = 5) => {
let active = 0, q = [];
const run = (...args) => new Promise((resolve, reject) => {
const task = async () => {
active++;
try { resolve(await fn(...args)); }
catch (e) { reject(e); }
finally {
active--;
if (q.length) q.shift()();
}
};
active < pool ? task() : q.push(task);
});
return run;
};
17) Quick reference (cheat sheet)

Conclusion
async
/await
is just Promises + a state machine + microtasks dressed in readable syntax. When you remember that:
- You’ll know why logs come out in that order.
- You’ll avoid accidental serialization by starting work first and awaiting together.
- You’ll handle errors correctly with real
try/catch/finally
. - You’ll design production-grade flows with timeouts, cancellation, retries, and concurrency limits.
Pro tip: When debugging, narrate it: “This function returns a promise. At this await
, it yields. The continuation is a microtask. Did I start the other promises yet? Who’s catching errors?” That mindset solves 90% of async confusion.
Call to Action
What’s the trickiest async/await
bug you’ve hit — unhandled rejection, serialized loop, or a mysteriously late log?
💬 Share in the comments.
🔖 Bookmark this as your async internals cheat sheet.
👩💻 Share with a teammate who thinks await
is a blocking call.
Leave a Reply