apollographql / apollographql/apollo-client-integrations
react-router: query slower than `streamTimeout` truncates the transported queryRef and fails hydration with a misleading `InvariantViolation`
- Dominant language
- TypeScript
- Stars
- 556
- Forks
- 53
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
When a query preloaded via `preloadQuery` in an `apolloLoader` takes longer than React Router's `streamTimeout`, React Router aborts the turbo-stream encode. The transported query ref is truncated, so on the client the Apollo link chain completes without ever emitting a value, and Apollo throws:
```
Invariant Violation: The link chain completed without emitting a value.
This is likely unintentional and should be updated to emit a value before completing.
```
The error propagates to the route's `ErrorBoundary`, so the user gets an error page — replacing HTML that had already rendered correctly on the server.
**Minimal reproduction:** https://github.com/thelooter/apollo-rr-loader-repro
## Reproduction
```bash
git clone https://github.com/thelooter/apollo-rr-loader-repro
cd apollo-rr-loader-repro
# terminal 1 — mock GraphQL API, slower than streamTimeout
cd mock-api && DELAY_MS=6000 node server.mjs
# terminal 2
cd rr8 && pnpm install && pnpm dev
```
Open the page: `loading…`, then **Oops!** with the invariant violation.
Set `DELAY_MS=2000`, restart the API, reload — renders and hydrates cleanly. Or keep `DELAY_MS=6000` and run `STREAM_TIMEOUT=30000 pnpm dev`, which also works.
Both apps are wired straight from the package README. The mock API returns canned JSON with no resolver work, so `DELAY_MS` *is* the query latency.
## Results
| Query latency | React Router 7.18.2 | React Router 8.3.0 |
|---|---|---|
| **2000ms** (under `streamTimeout`) | ✅ TTFB 0.016s, data streams in, hydrates clean | ✅ TTFB 0.008s, data streams in, hydrates clean |
| **6000ms** (over `streamTimeout`) | ❌ fallback-only HTML, then `link chain completed` + error boundary | ❌ identical |
Under the timeout everything behaves exactly as documented — the shell flushes immediately with the Suspense fallback and the data streams into the same response. **Not React Router 8-specific:** 7.18.2 fails identically, so this isn't a v8 regression.
## Mechanism
`encodeViaTurboStream` in `react-router/dist/.../server-runtime/single-fetch.js`:
```js
let timeoutId = setTimeout(() => {
controller.abort(new Error("Server Timeout"));
cleanupCallbacks();
}, typeof streamTimeout === "number" ? streamTimeout : 4950);
```
Server-side you get an unhandled `Error: Server Timeout` from that line; client-side, the truncated stream means `ReadFromReadableStreamLink` sees completion with no value.
## Why I think it's worth addressing
Exceeding `streamTimeout` failing is arguably correct behaviour. The problem is the shape of the failure:
- The error names none of the actual causes — not `streamTimeout`, not turbo-stream, not the slow query. "The link chain completed without emitting a value" reads like an Apollo Link misconfiguration and sends you looking in entirely the wrong place.
- It arrives as an unhandled render error rather than a timeout, so there's no clean way to catch it and fall back to a client-side fetch.
- The threshold is invisible. `react-router reveal` scaffolds `streamTimeout = 5_000` and nothing connects that number to Apollo, so the first encounter is typically a blank error page in production on your slowest route.
A targeted error when the transported stream is aborted mid-flight — naming `streamTimeout` and suggesting raising it — would turn a multi-hour debugging session into a one-line fix.
## Versions
`@apollo/client-integration-react-router` 0.14.5-alpha.0, `@apollo/client` 4.2.6, react/react-dom 19.2.7, react-router 8.3.0 and 7.18.2, Node 24.19.0.
Contributor guide
No contributing guide indexed for this repository
Research direction
Reproduce the failure with mock-api/server.mjs and the rr8 app, varying DELAY_MS and STREAM_TIMEOUT. Inspect encodeViaTurboStream in react-router's server-runtime/single-fetch.js and the ReadFromReadableStreamLink completion path; done means an aborted transported stream produces a clear timeout-related error rather than the misleading invariant violation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- graphql, node.js, react, typescript
- Domain
- api, backend, full-stack
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100