Node.js, part 11: fs.promises and AbortController — doing two things at once, then stopping one
Part 11from the Node.js series · 24 parts in all
Two changes made cancellation a first-class idea in Node. The file system got a real
promise API (fs.promises, later node:fs/promises), and the
AbortController that came from the browser was adopted across core APIs. Part 11
is the pair, because they solve the same problem from two directions: overlapping work, and
giving up on it.
fs.promises: the API you probably want
const fs = require('fs/promises');
// One import replaces the promisify boilerplate of part 2.
const entries = await fs.readdir('data', { withFileTypes: true });
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
const stats = await Promise.all(files.map((f) => fs.stat(`data/${f}`)));
It is not a wrapper over the callback version — it is a separate implementation in
libuv, marginally faster and with cleaner error objects. fs.rm with a
recursive option also replaces the old rimraf dependency, which
used to be a package almost every project installed.
AbortController: one signal, many APIs
const controller = new AbortController();
const { signal } = controller;
const p = fs.readFile('huge.log', { signal });
setTimeout(() => controller.abort(), 200); // give up after 200ms
try {
await p;
} catch (err) {
if (err.name === 'AbortError') console.log('cancelled as intended');
else throw err;
}
The value of a shared signal type is that one controller can cancel a whole operation:
a file read, an fetch, a SQL query through a driver that supports it. That is
what AbortSignal.timeout(ms) and AbortSignal.any([...]) compose
into — a deadline plus a user-cancel, without hand-rolling the bookkeeping.
What cancellation is not
Aborting stops your code waiting; it cannot always stop the work underneath. A libuv read already in flight finishes and its buffer is discarded. The same is true of a remote query. So an abort is best understood as "detach and release the resources I hold", which is exactly what you want for a request whose client hung up — and it is why the cleanup path still has to run. Next: top-level await, which finally let a module be its own initialisation sequence.