apollographql / apollographql/apollo-client-integrations
`TeeToReadableStreamLink`: unguarded `controller.enqueue()` throws "Invalid state: Controller is already closed" as a fatal uncaughtException during SSR
- Dominant language
- TypeScript
- Stars
- 556
- Forks
- 53
- PR merge metrics
- No merged PRs in 30d
Description
## Environment
- `@apollo/client-integration-nextjs` / `@apollo/client-react-streaming`: **0.14.5**
- `@apollo/client`: 4.1.7
- `next`: 16.2.4 (App Router, standalone output, Turbopack build)
- `react`: 19.2.5
- Node.js: v24.18.1 (Alpine, AWS ECS Fargate)
## Description
In production we see the Node process throw an **uncaught exception** (reported by Sentry with `mechanism: auto.node.onuncaughtexception`, level `fatal`) during RSC/SSR rendering:
```
TypeError: Invalid state: Controller is already closed
at ReadableStreamDefaultController.enqueue (node:internal/webstreams/readablestream:1105:13)
at Object.next (@apollo/client-react-streaming/dist/index.rsc.js:45:26)
controller.enqueue({ type: "next", value: result });
at Subscriber.next (rxjs/src/internal/Subscriber.ts:155)
at ErrorLink observer next (@apollo/client/link/error/index.ts:150)
retriedSub = retriedResult?.subscribe(observer);
...
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
```
The crash happens when a query result is delivered **after the ReadableStream created by `teeToReadableStream` has already been closed** — in our case when the browser (typically mobile Safari) disconnects mid-render and Next.js aborts the RSC stream. Any link that delays result delivery by a tick (we have an `ErrorLink` in the chain; a retry link would do the same) widens the race window.
## Root cause
In `packages/client-react-streaming/src/ReadableStreamLink.tsx` (`TeeToReadableStreamLink`), `controller.close()` is defensively wrapped, but the three `controller.enqueue()` calls are not:
```ts
const tryClose = () => {
try {
controller.close();
} catch {}
};
forward(operation).subscribe({
next(result) {
controller.enqueue({ type: "next", value: result }); // ← throws if already closed
if (subscribed) observer.next(result);
},
error(error) {
controller.enqueue({ type: "error" }); // ← same
tryClose();
...
},
complete() {
controller.enqueue({ type: "completed" }); // ← same
tryClose();
...
},
});
```
Once the stream's consumer is gone (client disconnect → RSC render abort → controller closed/canceled), the next `enqueue` throws synchronously inside an RxJS `next` handler with no error path left, so on the server it escalates to `uncaughtException` and can take the whole Node process down (in our case recycling the ECS task and severing every other in-flight request on that instance).
## Expected behavior
A closed transport stream should be treated the same way `tryClose` already treats it: silently. The client that disconnected can't receive the data either way; crashing the server process punishes unrelated requests.
## Suggested fix
Guard `enqueue` the same way `close` is guarded, e.g.:
```ts
const tryEnqueue = (event: ReadableStreamLinkEvent) => {
try {
controller.enqueue(event);
} catch {}
};
```
(or track a `closed` flag / check `controller.desiredSize === null` before enqueueing).
Happy to open a PR if you'd take one.
## Occurrence data
13 events / 12 users over ~2 days in production on a mid-traffic site, exclusively on pages with several parallel `PreloadQuery`/`useSuspenseQuery` operations; every event correlates with a mobile client and looks like a navigation-away/disconnect mid-render.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in packages/client-react-streaming/src/ReadableStreamLink.tsx at TeeToReadableStreamLink and inspect the guarded close path alongside the three enqueue calls. Exercise the stream-abort race with delayed RxJS delivery; done means a result, error, or completion after cancellation is ignored without an uncaught exception.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, react, typescript
- Domain
- backend, web-dev
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100