apollographql / apollographql/apollo-client-integrations

`TeeToReadableStreamLink`: unguarded `controller.enqueue()` throws "Invalid state: Controller is already closed" as a fatal uncaughtException during SSR

Open Beginner friendly
#561 0 comments 1 reaction 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.