Reproduction
The bug is in createReadableStreamFromReadable itself, so it reproduces with no app, no router and no HTTP server — two files, two commands:
package.json
{ "name": "repro", "type": "module", "dependencies": { "@react-router/node": "8.3.0" } }
repro.mjs
import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@react-router/node";
process.on("uncaughtException", (error) => {
console.log("UNCAUGHT EXCEPTION:", error.name, "-", error.message);
console.log(error.stack.split("\n").slice(0, 6).join("\n"));
process.exit(1);
});
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
// The consumer of the response body. Compression is what makes Node synthesize
// an AbortError on cancel; without it the same cancel is silent.
const compressed = stream.pipeThrough(new CompressionStream("gzip"));
const reader = compressed.getReader();
body.write("<!DOCTYPE html><html>");
await reader.read();
// What a response writer does when the client goes away mid-stream.
await reader.cancel();
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("no crash");
npm install && node repro.mjs
Prints UNCAUGHT EXCEPTION: AbortError - The operation was aborted and exits 1. Without the uncaughtException handler the process dies outright.
I did not use a StackBlitz or a fork of the integration suite because neither adds anything here — there is no app to scaffold, and the failing path is 20 lines of library code. Happy to publish it as a repo if you'd prefer a link.
System Info
System:
OS: macOS 26.5.2
CPU: (10) arm64 Apple M1 Pro
Memory: 1.99 GB / 32.00 GB
Binaries:
Node: 26.5.1
npm: 11.4.2
pnpm: 11.15.1
npmPackages:
@hono/node-server: 2.0.12 => 2.0.12
@react-router/dev: 8.3.0 => 8.3.0
@react-router/node: 8.3.0 => 8.3.0
react-router: 8.3.0 => 8.3.0
vite: 8.2.0
Used Package Manager
pnpm
Expected Behavior
Cancelling a ReadableStream produced by createReadableStreamFromReadable should tear the underlying Node stream down quietly. A client that disconnects mid-response is routine, not a defect, and it should not be able to raise an uncaughtException.
Actual Behavior
It raises one, and in a default SSR setup that kills the server process.
StreamPump.cancel destroys the stream and then immediately detaches its own error listener:
https://github.com/remix-run/react-router/blob/main/packages/react-router-node/stream.ts (dist/index.js:255-261 in 8.3.0)
cancel(reason) {
if (this.stream.destroy) this.stream.destroy(reason); // <- requests the error
this.stream.off("data", this.enqueue);
this.stream.off("error", this.error); // <- removes the listener for it
this.stream.off("end", this.close);
this.stream.off("close", this.close);
}
destroy(reason) emits 'error' asynchronously, so by the time it fires the listener registered in start() is gone. A Node stream that emits 'error' with no listener rethrows, which surfaces as an uncaughtException.
The reason matters for whether there is an error to emit at all. When the consumer is a CompressionStream — i.e. any SSR response behind compression middleware — the cancel goes through Node's webstream adapter, which calls destroy(stream, undefined), and destroy.js:328 synthesizes an AbortError when the reason is falsy and the stream is unfinished. That error then propagates back through pipeThrough into StreamPump.cancel.
Full stack:
AbortError: The operation was aborted
at destroyer (node:internal/streams/destroy:328:11)
at Object.cancel (node:internal/webstreams/adapters:498:7)
at Object.cancelAlgorithm (node:internal/webstreams/util:190:25)
at readableStreamDefaultControllerCancelSteps (node:internal/webstreams/readablestream:2612:37)
at [kCancel] (node:internal/webstreams/readablestream:1174:12)
Two counterfactuals in the same repro, both confirming the diagnosis:
- add
body.on("error", () => {}) before createReadableStreamFromReadable → no crash
- drop the
pipeThrough(new CompressionStream("gzip")) → no crash (nothing synthesizes an error object, so the cancel is silent)
Why this matters in a framework-mode app
entry.server.tsx as generated by the template hands its PassThrough to createReadableStreamFromReadable and attaches no error listener of its own, so the stream's only listener is the one StreamPump removes. Any compressed streaming SSR response is therefore one client-disconnect away from taking the process down. We hit it in production behind Hono's compress(): GET /mentor/dashboard answered 200, and ten seconds later the pod had restarted. With Sentry's onUncaughtExceptionIntegration installed the exit is explicit (logAndExitProcess), but plain Node dies just the same.
We've worked around it by keeping our own error listener on the PassThrough, which is easy enough once you know — but it is not obvious from the template, and the failure mode (a whole server restart from a closed tab) is severe.
Possible fix
Reorder cancel so the listener outlives the destroy it requested, e.g. detach the other three immediately and let error/close clean themselves up, or swallow the error explicitly:
cancel(reason) {
this.stream.off("data", this.enqueue);
this.stream.off("end", this.close);
this.stream.off("close", this.close);
if (this.stream.destroy) this.stream.destroy(reason);
}
I patched exactly that into node_modules and re-ran the repro: it prints no crash, and reverting the patch brings the AbortError straight back. Happy to open a PR in that shape, or another if you'd rather solve it differently.
Reproduction
The bug is in
createReadableStreamFromReadableitself, so it reproduces with no app, no router and no HTTP server — two files, two commands:package.json{ "name": "repro", "type": "module", "dependencies": { "@react-router/node": "8.3.0" } }repro.mjsnpm install && node repro.mjsPrints
UNCAUGHT EXCEPTION: AbortError - The operation was abortedand exits 1. Without theuncaughtExceptionhandler the process dies outright.I did not use a StackBlitz or a fork of the integration suite because neither adds anything here — there is no app to scaffold, and the failing path is 20 lines of library code. Happy to publish it as a repo if you'd prefer a link.
System Info
System: OS: macOS 26.5.2 CPU: (10) arm64 Apple M1 Pro Memory: 1.99 GB / 32.00 GB Binaries: Node: 26.5.1 npm: 11.4.2 pnpm: 11.15.1 npmPackages: @hono/node-server: 2.0.12 => 2.0.12 @react-router/dev: 8.3.0 => 8.3.0 @react-router/node: 8.3.0 => 8.3.0 react-router: 8.3.0 => 8.3.0 vite: 8.2.0Used Package Manager
pnpm
Expected Behavior
Cancelling a
ReadableStreamproduced bycreateReadableStreamFromReadableshould tear the underlying Node stream down quietly. A client that disconnects mid-response is routine, not a defect, and it should not be able to raise anuncaughtException.Actual Behavior
It raises one, and in a default SSR setup that kills the server process.
StreamPump.canceldestroys the stream and then immediately detaches its ownerrorlistener:https://github.com/remix-run/react-router/blob/main/packages/react-router-node/stream.ts (
dist/index.js:255-261in 8.3.0)destroy(reason)emits'error'asynchronously, so by the time it fires the listener registered instart()is gone. A Node stream that emits'error'with no listener rethrows, which surfaces as anuncaughtException.The
reasonmatters for whether there is an error to emit at all. When the consumer is aCompressionStream— i.e. any SSR response behind compression middleware — the cancel goes through Node's webstream adapter, which callsdestroy(stream, undefined), anddestroy.js:328synthesizes anAbortErrorwhen the reason is falsy and the stream is unfinished. That error then propagates back throughpipeThroughintoStreamPump.cancel.Full stack:
Two counterfactuals in the same repro, both confirming the diagnosis:
body.on("error", () => {})beforecreateReadableStreamFromReadable→ no crashpipeThrough(new CompressionStream("gzip"))→ no crash (nothing synthesizes an error object, so the cancel is silent)Why this matters in a framework-mode app
entry.server.tsxas generated by the template hands itsPassThroughtocreateReadableStreamFromReadableand attaches noerrorlistener of its own, so the stream's only listener is the oneStreamPumpremoves. Any compressed streaming SSR response is therefore one client-disconnect away from taking the process down. We hit it in production behind Hono'scompress():GET /mentor/dashboardanswered 200, and ten seconds later the pod had restarted. With Sentry'sonUncaughtExceptionIntegrationinstalled the exit is explicit (logAndExitProcess), but plain Node dies just the same.We've worked around it by keeping our own
errorlistener on thePassThrough, which is easy enough once you know — but it is not obvious from the template, and the failure mode (a whole server restart from a closed tab) is severe.Possible fix
Reorder
cancelso the listener outlives thedestroyit requested, e.g. detach the other three immediately and leterror/closeclean themselves up, or swallow the error explicitly:I patched exactly that into
node_modulesand re-ran the repro: it printsno crash, and reverting the patch brings theAbortErrorstraight back. Happy to open a PR in that shape, or another if you'd rather solve it differently.