get-convex / get-convex/convex-react-query

Hydration mismatch on slow networks: SSR data briefly gets overwritten with empty/unauthenticated data when using auth

Open
#33 0 comments 1 reaction 0 assignees View on GitHub
Dominant language
TypeScript
Stars
41
Forks
7
PR merge metrics
No merged PRs in 30d

Description

### What's happening

When I hard-refresh a page on a throttled connection (Chrome DevTools "Fast 4G"), my Convex-powered TanStack Start app:

1. Renders fine on the server with my real authenticated data.
2. Then briefly flashes to "no data found" (the empty-array branch of my Convex query) just as React is hydrating.
3. Throws a React hydration mismatch error in the console.
4. Recovers a moment later and shows the correct data again.

On a fast network I never see this — everything works. It only shows up under network throttling or, presumably, on real-world slow connections.

This feels like the same family of problem as #19 ("Prevent a flash after hydration when using auth in SSR"), but in my case it's bad enough to throw a hydration error, not just a brief visual flicker — so I figured it was worth its own issue with a concrete reproduction. Happy to merge this into #19 if a maintainer prefers.

### Reproduction

I'm following the [Convex TanStack Start guide](https://docs.convex.dev/client/react/tanstack-start/) and the [Convex + TanStack Query guide](https://docs.convex.dev/client/tanstack/tanstack-query/), with Clerk added via the [Clerk TanStack Start quickstart](https://clerk.com/docs/tanstack-react-start/getting-started/quickstart).

1. A Convex query that returns `[]` when there's no authenticated user (a fairly common pattern):

```ts
// convex/events.ts
export const listMyEvents = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
// ...return the user's events
},
});
```

2. The route prefetches it in a loader (the server has a Clerk JWT set on the server HTTP client via `beforeLoad`), and the component reads it with `useSuspenseQuery`:

```ts
loader: ({ context }) => {
void context.queryClient.prefetchQuery(
convexQuery(api.events.listMyEvents, {}),
);
},
```

```tsx
function EventsContent() {
const { data } = useSuspenseQuery(
convexQuery(api.events.listMyEvents, {}),
);
// The component renders something conditionally based on `data`,
// e.g. a "Show past events" button only when `data.length > 0`.
}
```

3. `convexQueryClient.connect(queryClient)` is called inside `getRouter()` (per the docs).

4. `ConvexProviderWithClerk` lives in the root component — it calls `setAuth()` from a `useEffect`.

5. In Chrome DevTools, throttle to "Fast 4G" and hard-refresh.

### What I see

- The server-rendered HTML contains my data correctly.
- During hydration, the page flashes to the empty-state branch ("no events found") for a moment.
- A React hydration error appears in the console — the diff shows a `` that was in the server HTML but missing from the client render. The button is the one that's only shown when my query returns data.
- After the error, the page re-renders with the correct data.

I added some logging and the cache value for the query *changes from "8 events" to `[]` and back to "8 events"* during this window — i.e. something is overwriting the dehydrated SSR data with an empty array.

### Things I noticed while digging (low confidence — might be wrong)

I tried to figure out what was overwriting my cache, and I'm not confident I have the full picture, but two things stood out and might be useful starting points:

1. **Auth timing.** `ConvexProviderWithClerk` sets the auth token from a `useEffect`, which runs *after* React commits. But `convexQueryClient.connect()` runs immediately during `getRouter()`, before any React rendering. So when hydration restores the dehydrated data and that fires an `"added"` event in the `QueryCache`, the resulting Convex subscription seems to start on a WebSocket that isn't yet authenticated. My query's `if (!identity) return []` branch then runs, and `[]` gets written into the cache — overwriting the SSR data. This matches what @thomasballinger described in #19.

2. **A `// TODO pass journals through` in `src/index.ts`.** While searching for what was writing `[]` into the cache, I came across this in the `"added"` branch of `subscribeInner` ([`src/index.ts#L300-L305`](https://github.com/get-convex/convex-react-query/blob/0954a66d831714968b3705bba8b18af6d70eb34e/src/index.ts#L300-L305)):

```ts
const watch = this.convexClient.watchQuery(
func,
args,
// TODO pass journals through
{},
);
```

I don't fully understand the journal/`watchQuery` machinery, but the [Convex TanStack Start guide](https://docs.convex.dev/client/react/tanstack-start/) does describe "subscription session resumption, from SSR to live on the client" as one of the things this stack is meant to give you, and from the surrounding code I gathered that the journal is what's supposed to make that work. I'm flagging it in case it's relevant; I'll leave the call on whether it actually is up to someone who knows the codebase.

### Workaround that seems to work for me

I moved `convexQueryClient.connect(queryClient)` out of `getRouter()` and into a `useEffect` in the root shell component. As far as I can tell, React fires child effects before parent effects, so `ConvexProviderWithClerk.setAuth()` runs before `connect()`, and the subscriptions start on an already-authenticated WebSocket. After this change I can't reproduce the hydration error anymore, even on throttled connections.

```tsx
// src/routes/__root.tsx — shellComponent
function RootDocument({ children }: { children: React.ReactNode }) {
const { convexQueryClient, queryClient } = useRouteContext({ from: Route.id });
const hasConnectedRef = useRef(false);

useEffect(() => {
if (hasConnectedRef.current) return;
hasConnectedRef.current = true;
convexQueryClient.connect(queryClient);
}, [convexQueryClient, queryClient]);

// ...
}
```

```ts
// src/router.tsx — connect() removed from here
export function getRouter() {
const convexClient = new ConvexReactClient(CONVEX_URL, { /* ... */ });
const convexQueryClient = new ConvexQueryClient(convexClient);
const queryClient = new QueryClient({ /* ... */ });
// convexQueryClient.connect(queryClient) — moved into the shell's useEffect

const router = createTanStackRouter({ /* ... */ });
setupRouterSsrQueryIntegration({ router, queryClient });
return router;
}
```

This is just what worked for me — I have no idea if it's the right shape for an actual fix, or if it has downsides I haven't run into yet.

### Environment

- `@convex-dev/react-query`: 0.1.0
- `convex`: 1.34.1
- `@tanstack/react-query`: 5.95.2
- `@tanstack/react-router-ssr-query`: 1.166.10
- `@tanstack/react-start`: 1.167.12
- `@clerk/tanstack-react-start`: 1.0.7
- React: 19.2.4
- Node: 24

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.