C.W.K.
Stream
Lesson 03 of 05 · published

async / await — Sugar Over Promises (With Teeth)

~13 min · async, async-await, promises

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"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

Every async/await function has an exact promise-chain equivalent. They're the same machinery dressed differently.
// 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

I used to think 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.

Code

Sequential vs parallel — knowing the difference is the skill·javascript
// Sequential — only OK when later calls depend on earlier results
async function userBundle(id) {
  const user = await fetchUser(id);          // 100ms
  const posts = await fetchPosts(user.id);    // 100ms — sequential
  const tags = await fetchTags(user.id);      // 100ms — sequential
  return { user, posts, tags };               // total: 300ms
}

// Parallel — start the independent calls together, then await
async function userBundleFast(id) {
  const user = await fetchUser(id);            // 100ms (need this first)
  const [posts, tags] = await Promise.all([
    fetchPosts(user.id),                       // both start now
    fetchTags(user.id),                        // both run together
  ]);
  return { user, posts, tags };                // total: 200ms
}

// Even more parallel when nothing depends on each other
async function siteData() {
  const [home, about, contact] = await Promise.all([
    fetch('/api/home').then(r => r.json()),
    fetch('/api/about').then(r => r.json()),
    fetch('/api/contact').then(r => r.json()),
  ]);
  return { home, about, contact };
}
try/catch is the ergonomic win·javascript
// Error handling patterns

// Local handling
async function safeRead(path) {
  try {
    return await readFile(path, 'utf-8');
  } catch (err) {
    if (err.code === 'ENOENT') return null;  // file missing, return null
    throw err;  // anything else, propagate
  }
}

// Inline catch — fallback value
const raw = await readFile(path, 'utf-8').catch(() => '{}');
const data = JSON.parse(raw);

// Don't swallow without intent — this is a bug
async function bad() {
  try {
    await dangerousOp();
  } catch (e) {
    // swallowed — caller has no idea anything failed
  }
  return 'ok';  // lies
}

External links

Exercise

Take a function that fetches user data: getUser(id) returns user + posts + tags. Write it three ways: (1) all sequential awaits, (2) await user, then parallel posts/tags, (3) all three in parallel (assume tags can resolve without user.id, e.g., they're org-level tags). Time each. Then write a fourth: same as (2) but with try/catch so a tags failure doesn't lose the user data — return partial when tags fail.
Hint
For (4), wrap just the parallel call: try { [posts, tags] = await Promise.all([...]) } catch (e) { tags = []; posts = await fetchPosts(user.id); }. Or use Promise.allSettled to get both results regardless and inspect each status.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.