Does Node.js automatically propagate AbortSignal to legacy callback‑based APIs?
0 reputation · 28 Jan 2023, 02:27 UTC
0 reputation · 28 Jan 2023, 02:27 UTC
Determine whether Node.js can automatically propagate an AbortSignal to legacy callback‑based APIs such as fs.readFile with a callback, which lack a signal option, and understand the implications for graceful cancellation.
While promise‑based APIs accept an AbortSignal and reject with an AbortError when aborted, callback‑based functions do not expose a signal argument, leaving no built‑in mechanism to tie the abort state to the operation. This forces developers to create custom wrappers or use workarounds like Promise.race with a timeout, raising questions about a standardized approach.
Is there a recommended pattern to abort legacy callback‑based operations using AbortSignal? Can a wrapper be implemented that respects the signal without altering the original API? Does Node.js provide any internal mechanism to propagate abort signals to callbacks?
No — Node.js does not automatically propagate an AbortSignal to legacy callback-based APIs. A function like fs.readFile(path, callback) has no signal parameter and no internal plumbing to watch one. If you abort an AbortController, the underlying file read continues, the callback still fires, and nothing on the Node.js side connects the two.
AbortSignal support in Node.js is opt-in per API. An API only respects a signal if its implementation explicitly checks signal.aborted or listens for the 'abort' event and then tears down the underlying operation. Legacy callback APIs predate AbortController and were never retrofitted with that logic. There is no global registry where the runtime matches signals to in-flight callbacks — the signal is just an event emitter unless some code subscribes to it.
The standard workaround is to wrap the callback API in a Promise and reject early when the signal fires:
function readFileWithSignal(path, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
return reject(signal.reason ?? new Error('Aborted'));
}
const onAbort = () => reject(signal.reason ?? new Error('Aborted'));
signal.addEventListener('abort', onAbort, { once: true });
require('fs').readFile(path, (err, data) => {
signal.removeEventListener('abort', onAbort);
if (err) return reject(err);
resolve(data);
});
});
}This gives callers correct JavaScript-side semantics — the promise rejects promptly and your await chain unblocks. But be clear about what it does not do:
'abort' listener when the operation settles (as above). Adding listeners to a long-lived signal without cleanup is a classic slow memory leak.For file system work, fs.promises.readFile(path, { signal }) accepts an AbortSignal natively and rejects with an AbortError, with the runtime actually participating in the cancellation. The same applies to modern promise-based APIs across the standard library (fetch, streams, timers/promises, child processes via signal options). Where a native option exists, prefer it over any wrapper — that's the only way cancellation reaches the underlying operation.
You can confirm the asymmetry in a minute:
const fs = require('fs');
const ac = new AbortController();
// Legacy: callback still fires after abort
fs.readFile('/some/large/file', () => console.log('callback ran'));
ac.abort();
// Native: rejects with AbortError
fs.promises.readFile('/some/large/file', { signal: ac.signal })
.catch(e => console.log(e.name)); // 'AbortError'Bottom line: there is no internal propagation mechanism and no standardized adapter in Node.js. A signal-aware wrapper is the correct pattern for APIs you can't change — just treat it as early rejection of the result, not true cancellation, and migrate to signal-accepting APIs wherever one exists.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.