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
- Ensure the writable passed to writeReadableStreamToWritable has NOT been ended/destroyed before the call (don't double-end responses).
- If using an abort signal, stop producing the ReadableStream and let the function unwind instead of destroying the writable externally.
- In custom adapters, only call res.end()/res.destroy() AFTER writeReadableStreamToWritable resolves/rejects.
- 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
- Never call res.end()/res.destroy() before writeReadableStreamToWritable resolves.
- Guard adapters: if (res.writableEnded || res.destroyed) return;
- Let the helper own the writable lifecycle (it ends on stream completion).
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
- There was a problem extracting the file from the provided te
- No handlers were found for the request: ${url.pathname}${url
- Prerender (data): Received a ${response.status} status code
- ${method}() call aborted without an `AbortSignal.reason`: ${
- You cannot call `runClientMiddleware()` from a static handle
AI-assisted analysis of remix-run/react-router@1fd704a7da (2026-08-12).
Data as JSON: /api/errors/567d62d157b389eb.
Report an issue: GitHub.