remix-run/react-router · error · Error

Cannot write to a destroyed or ended writable stream

Error message

Cannot write to a destroyed or ended writable stream

What it means

Thrown by monitorWritableError's throwIfClosed() (stream.ts) when writeReadableStreamToWritable attempts to read/write but writable.destroyed or writable.writableEnded is already true. This protects against pushing data into a Node Writable that the consumer has already ended or errored — the underlying stream would emit 'write after end' / 'Error [ERR_STREAM_DESTROYED]'. The monitor also captures prior 'error'/'close' events and rethrows those first.

Source

Thrown at packages/react-router-node/stream.ts:105

  function onClose() {
    reject(new Error("Writable closed before stream finished"));
  }

  writable.once("error", onError);
  writable.once("close", onClose);

  return {
    cleanup,
    race<T>(promise: Promise<T>) {
      return Promise.race([promise, writableErrorPromise]);
    },
    throwIfClosed() {
      if (writableError) {
        throw writableError;
      }

      if (writable.destroyed || writable.writableEnded) {
        throw new Error("Cannot write to a destroyed or ended writable stream");
      }
    },
  };
}

function waitForDrain(
  writable: Writable,
  writableError: WritableErrorMonitor,
): Promise<void> {
  let cleanup = () => {};
  let drainPromise = new Promise<void>((resolve) => {
    function onDrain() {
      cleanup();
      resolve();
    }

    cleanup = function cleanup() {
      writable.off("drain", onDrain);

View on GitHub (pinned to 1fd704a7da)

Solutions

  1. Ensure the writable passed to writeReadableStreamToWritable has NOT been ended/destroyed before the call (don't double-end responses).
  2. If using an abort signal, stop producing the ReadableStream and let the function unwind instead of destroying the writable externally.
  3. In custom adapters, only call res.end()/res.destroy() AFTER writeReadableStreamToWritable resolves/rejects.
  4. Add a guard around the call site: if (res.writableEnded || res.destroyed) return;

Example fix

// custom node adapter — before
export async function sendResponse(webRes, nodeRes) {
  // … copy headers/status …
  nodeRes.end();                       // ends BEFORE the stream is written
  if (webRes.body) await writeReadableStreamToWritable(webRes.body, nodeRes);
}
// after
export async function sendResponse(webRes, nodeRes) {
  // … copy headers/status …
  if (webRes.body) {
    await writeReadableStreamToWritable(webRes.body, nodeRes); // ends internally on done
  } else {
    nodeRes.end();
  }
}
Defensive patterns

Strategy: type-guard

Validate before calling

import type { Writable } from 'node:stream';
function isWritableReady(w: Writable): boolean {
  return !w.destroyed && !w.writableEnded;
}
if (!isWritableReady(nodeRes)) throw new Error('Response writable is ended/destroyed before stream write');

Type guard

function isWritableReady(w: { destroyed: boolean; writableEnded: boolean }): boolean {
  return !w.destroyed && !w.writableEnded;
}

Try / catch

try { await writeReadableStreamToWritable(stream, writable); }
catch (e) {
  if (e instanceof Error && /destroyed or ended writable stream/.test(e.message)) {
    // the response was closed prematurely; nothing more to write, log and stop
  }
  throw e;
}

Prevention

When it happens

Trigger: writeReadableStreamToWritable(stream, writable) is called after the response writable was already .end()'d or .destroy()'d by middleware/upstream code. Or the writable emits 'close' mid-flight, then the loop calls throwIfClosed before the next read/write.

Common situations: An Express/Node adapter that calls res.end() prematurely, then the framework tries to pipe a ReadableStream to it. A custom server adapter that destroys the response on a timeout while a stream is being written. Calling writeReadableStreamToWritable twice on the same writable. AbortController triggering res.destroy() during streaming.

Related errors


AI-assisted analysis of remix-run/react-router@1fd704a7da (2026-08-12). Data as JSON: /api/errors/567d62d157b389eb. Report an issue: GitHub.