Use stream.finished for reliable cleanup
Published August 17, 2027
import { createReadStream, createWriteStream } from "node:fs";
import { finished } from "node:stream/promises";
export const copyWithCleanup = async (src: string, dest: string, signal?: AbortSignal) => {
const read = createReadStream(src);
const write = createWriteStream(dest);
read.pipe(write);
try {
await finished(write, { signal, cleanup: true });
} catch (err) {
read.destroy();
write.destroy();
throw err;
}
};What
copyWithCleanup(src, dest, signal) pipes one file into another and awaits node:stream/promises' finished() to know exactly when the write side is fully done — success, error, or abort — then destroys both streams on any non-success path.
Why it matters
A stream can end for several different reasons, each surfaced as a different event: 'finish' for a writable that's done writing, 'end' for a readable that's done emitting, 'close' once the underlying resource is released, 'error' if something went wrong. An error on one side of a .pipe() doesn't automatically become an event on the other side. Listening to all of that correctly, without a leaked listener or a double-fired cleanup, is the kind of code every codebase reinvents slightly differently. finished() collapses it into one promise that resolves exactly once, for exactly the right reason.
How it works
read.pipe(write)starts the actual copy..pipe()doesn't return a promise or otherwise tell you when it's done — that's the gapfinished()fills.finished(write, { signal, cleanup: true })awaits the writable side specifically. Waiting onwrite(rather thanread) is what actually confirms the destination file is fully flushed to disk, not just that the source finished emitting data.- The
signaloption ties the wait to anAbortSignal— abort it, andfinished()rejects with anAbortErrorimmediately, without manually wiring asignal.addEventListener('abort', ...). cleanup: trueremoves the internal listenersfinished()attaches to the stream once the promise settles. Without it, they're left attached — harmless for a short script, but a real listener leak in a long-lived process copying many files over its lifetime.- The
catchblock explicitly destroys both streams..pipe()doesn't automatically destroy the source when the destination errors, so an error onwritecan leavereadopen and holding a file descriptor unless something destroys it.
Gotchas
finished()only observes a stream — it doesn't consume one. Awaiting it on a readable that nobody is piping or reading from just hangs forever, since it never reaches'end'.finished()waits on whichever roles the stream actually has. Pass{ readable: false }or{ writable: false }explicitly to narrow it — aDuplex/Transformstream is both readable and writable, and without narrowing,finished()waits on completion signals from a side you might not actually be using.cleanupdefaults tofalse. Leaving it off is what most examples do, and it's fine for a one-off script, but it's a real leak in a server process handling many streams over its lifetime.
Related
Readable.toWeb— bridges a Node stream to the Web Streams API, which has its own separate completion signal (the stream'sclosedpromise) instead offinished().pipeline()fromnode:stream/promises— does the piping and the cleanup together, destroying every stream in the chain automatically on error. Reach for it instead of.pipe()+finished()when there's nothing else happening between the two calls; this snippet keeps them separate specifically to show whatfinished()does on its own.