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.