, ,

Async/Await Internals: How it Actually Works

Posted by

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

A plain-English deep dive into what really happens when you 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 a Promise.
  • await expr is equivalent to:
  1. evaluate expr,
  2. convert it to a Promise via Promise.resolve(expr),
  3. pause this function,
  4. 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 XPromise.resolve(X).
 
throw EPromise.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 becomes await 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 inside try, the rejection escapes the try/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> without type="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

Your email address will not be published. Required fields are marked *