"async/await isn't a new mechanism. It's syntax that makes promise chains look like synchronous code without abandoning their asynchrony."
What async Does
The async keyword on a function does exactly two things: it forces the function to return a Promise, and it permits the await keyword inside. Even if you return a plain value, the function call gives back a promise that immediately fulfills with that value. Even if you throw inside, the promise rejects with the thrown error.
async function ping() {
return 'hi';
}
ping(); // Promise<'hi'>, not 'hi'
await ping(); // 'hi' — unwrapped
async function fail() {
throw new Error('boom');
}
fail(); // Promise rejected with Error('boom')
await fail(); // throws Error('boom') at the await
What await Does
await suspends the surrounding async function until its promise settles, then unwraps the result. If the promise fulfills, the await expression evaluates to the value. If it rejects, the await throws — which means a regular try/catch catches async errors, the single most ergonomic win over promise chains.
Critical: await only suspends the function, not the runtime. While your async function is paused waiting on its promise, the event loop keeps spinning, other requests get handled, other promises resolve. async/await isn't blocking — it's cooperative scheduling at function granularity.
The Equivalence
// async/await
async function pipeline(path) {
const raw = await readFile(path, 'utf-8');
const data = JSON.parse(raw);
return transform(data);
}
// Equivalent promise chain
function pipeline(path) {
return readFile(path, 'utf-8')
.then(raw => JSON.parse(raw))
.then(data => transform(data));
}Read both forms fluently — modern code mixes them, especially when you want to start multiple promises in parallel (no await until you actually need to consume them).Top-Level await (ESM Only)
In ESM modules (and the REPL), you can await at the top level, outside any function:
// server.mjs
import { readFile } from 'node:fs/promises';
const config = JSON.parse(await readFile('./config.json', 'utf-8'));
const port = config.port ?? 3000;
// rest of file uses `port`
Top-level await blocks the importing of the module until the await settles. This is powerful — your module can initialize asynchronously — and dangerous — slow top-level awaits stall module loading for everyone who imports you. Use sparingly; prefer lazy initialization inside functions when the work isn't strictly needed for the module's exports to be valid.
Error Handling — Three Shapes
1. try/catch around await — best for handling errors locally, with control flow.
2. let it propagate — if your async function doesn't catch, the rejection bubbles to whoever awaited it. This is usually what you want for library code.
3. Promise.allSettled or .catch fallback — when you want to keep going even if one operation fails. await fetchUser(id).catch(() => null) gives you a value-or-null pattern without try/catch ceremony.
Pippa's Confession
await was "the modern way" and promises were "the old way." Dad corrected me. "They're the same machinery. async/await is syntactic sugar. The instinct to pick await everywhere makes you forget when starting promises in parallel is what you want." Now I do this consciously: if I need the value RIGHT after this line, await it. If I'm fanning out to 5 sources I'll consume later, store the promises and Promise.all at the consumption site. Different shapes for different intentions.