Trust is earned, not given

A different perspective

2018-02-19 · Projects

Node.js, part 1: async/await without the callback pyramid

Part 1from the Node.js series · 24 parts in all

For most of Node's first decade, "asynchronous" meant a callback and a growing pile of indentation. Node 8 shipped async/await on top of promises, and Node 10 made it usable (the V8 upgrade fixed the performance cliff the early versions had). Part 1 is the transition, plus the one bug everyone writes on the first day.

Await turns a chain into a list

const fs = require('fs/promises');

async function loadConfig(path) {
  try {
    const text = await fs.readFile(path, 'utf8');
    return JSON.parse(text);
  } catch (err) {
    if (err.code === 'ENOENT') return {};   // a missing file is not fatal
    throw err;                              // everything else is
  }
}

Three things just happened that callbacks made painful: the error path is a try/catch instead of a first-argument check at every level, the exception propagates out of the function rather than to a callback that might forget to check it, and the return value is a real value.

The first-day bug: awaiting in a loop

An await inside a for loop runs the iterations one at a time. When the operations are independent that is not correctness, it is just slow:

// Sequential: 10 requests, one after another.
const pages = [];
for (const id of ids) {
  pages.push(await fetchPage(id));
}

// Concurrent: the same 10 requests, overlapped. Order is preserved.
const pages = await Promise.all(ids.map((id) => fetchPage(id)));

Reach for the loop when each step depends on the last, and Promise.all when they do not. Its sibling behaviour is worth knowing too: Promise.all rejects on the first failure but does not cancel the others — the requests still run, they just stop being your problem. When partial success is acceptable, Promise.allSettled returns every outcome instead.

An async function always returns a promise

Even if it returns immediately, even if it throws synchronously — the caller gets a promise, and an unhandled rejection will take the process down on a modern Node. That is the one rule that makes the rest consistent: return and throw become resolve and reject, and nothing else changes. Next: util.promisify, for the huge installed base of code that still speaks callbacks.