10LOC
Node.jsadvanced

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 gap finished() fills.
  • finished(write, { signal, cleanup: true }) awaits the writable side specifically. Waiting on write (rather than read) is what actually confirms the destination file is fully flushed to disk, not just that the source finished emitting data.
  • The signal option ties the wait to an AbortSignal — abort it, and finished() rejects with an AbortError immediately, without manually wiring a signal.addEventListener('abort', ...).
  • cleanup: true removes the internal listeners finished() 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 catch block explicitly destroys both streams. .pipe() doesn't automatically destroy the source when the destination errors, so an error on write can leave read open 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 — a Duplex/Transform stream is both readable and writable, and without narrowing, finished() waits on completion signals from a side you might not actually be using.
  • cleanup defaults to false. 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's closed promise) instead of finished().
  • pipeline() from node: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 what finished() does on its own.