Hydration semantics
When a query helper starts a subscription in the browser it first looks for data it can show
immediately. Where that data comes from decides both what data() holds and what status() reports
on the very first change detection — which is what makes hydration flash-free or not.
Initial-data precedence
Section titled “Initial-data precedence”| # | Source | Sets data() |
Resulting status | Why |
|---|---|---|---|---|
| 1 | Warm client cache | Yes | pending, with isRefetching() true |
The value came from the local Convex cache; the subscription must still confirm it. |
| 2 | Transferred server result | Yes | success immediately |
The server already rendered this value into the HTML; reporting anything else would contradict the DOM being hydrated. |
| 3 | Placeholder data | Yes | pending, with isPlaceholderData() true |
Invented data, never treated as a result. |
| 4 | Preserved previous value | Keeps it | pending, with isRefetching() true |
A resubscribe (args change or refetch()) keeps the last real value on screen. |
The warm cache is checked first, and only when the client is not disabled — a disabled client’s
client getter throws. A cache hit deliberately leaves the query pending: unlike a transferred
result, nothing has yet rendered from it, so waiting for the live subscription to confirm is the
honest state. Read it as “showing data, refreshing” via isRefetching().
Sources 3 and 4 interact: a real previous value wins over a placeholder. The placeholder is only resolved when there is no warm cache entry, no transferred result, and no real previous value to preserve.
A transferred undefined is a result, not a missing entry — the transfer format distinguishes the
two, so a query whose server-side result was undefined still seeds and still reports success.
(injectPaginatedQuery() is the exception: it seeds only a defined first page.)
The hydration window
Section titled “The hydration window”Transferred entries are readable only during the bootstrap/hydration window. The window closes
permanently the first time ApplicationRef.whenStable() resolves; after that, reads return nothing
and the live subscription is the only source of truth. This mirrors Angular’s own HttpClient
transfer cache.
Consequences worth planning for:
- A query created after stability — a lazily loaded route, a
@deferblock that hydrates later, a component mounted from a user interaction — never seeds, even if a matching entry is still present inTransferState. It performs a normal pending load. - Entries are not deleted when read. Several components mounting the same query with the same args during bootstrap all seed from the same entry, and a component that unmounts and remounts within the window seeds again.
Paginated queries
Section titled “Paginated queries”injectPaginatedQuery() transfers and seeds only its first page. The key is built from the full
first-page arguments — your own args plus paginationOpts: { numItems: initialNumItems, cursor: null }
— so initialNumItems is part of the identity: a server render with initialNumItems: 10 produces
no seed for a client that mounts with initialNumItems: 20.
A seeded first page sets results(), clears error(), sets isLoadingFirstPage() to false, and
derives canLoadMore()/isExhausted() from the page’s isDone flag. status() reads success
straight away.
Two behaviors follow from the underlying client:
- The mandatory
LoadingFirstPageemission is absorbed. A fresh paginated subscription always emitsLoadingFirstPagebefore any data. While a transferred seed is on screen, that first emission is ignored so it cannot blank out the server-rendered page. The next real emission (CanLoadMore,LoadingMore, orExhausted) replaces the seed and takes over from there. loadMore()is inert until then. TheloadMorefunction belongs to a live emission, and the seed does not carry one.
Key matching
Section titled “Key matching”A transferred entry is stored under:
cva:<queryName>:<argsKey>where argsKey is JSON.stringify(convexToJson(args)).
Build query arguments the same way everywhere. The most reliable form is a single exported builder used by the resolver and the component alike:
import { Component } from '@angular/core';import { convexQueryResolver, injectQuery } from 'convex-angular';import { FunctionReference } from 'convex/server';
import { Todo } from './convex/api';
// Stand-in for a generated query reference with more than one argument.const searchTodos = {} as FunctionReference<'query', 'public', { category: string; completed: boolean }, Todo[]>;
/** * Build the args in exactly one place. * * The TransferState key is `JSON.stringify(convexToJson(args))`, so * `{ category, completed }` and `{ completed, category }` are different keys * even though they are the same arguments. A single builder makes the property * order identical everywhere and keeps hydration matching. */export function searchTodosArgs(category: string): { category: string; completed: boolean } { return { category, completed: false };}
export const searchTodosResolver = convexQueryResolver(searchTodos, (route) => searchTodosArgs(route.paramMap.get('category') ?? 'work'),);
@Component({ selector: 'app-todo-search', template: ` @for (todo of todos.data() ?? []; track todo._id) { <p>{{ todo.title }}</p> } `,})export class TodoSearchComponent { // Same builder, same property order, same TransferState key. readonly todos = injectQuery(searchTodos, () => searchTodosArgs('work'));}The same discipline applies to values, not just order: an argument derived from Date.now(), a
random value, or a locale that differs between server and browser produces a different key and
misses the seed.
Debugging a miss
Section titled “Debugging a miss”If a query hydrates with a loading flash, check in this order:
- Was anything transferred at all? Search the served HTML for
cva:— the state script contains one entry per transferred query. No entry means the fetch failed, was skipped,fetchOnServerwasfalse, or the transfer was suppressed bytransferAuthenticatedResults: false. - Does the key match character for character? Compare the
cva:<queryName>:<argsKey>in the HTML against the args your component builds — property order included. - Is the query created during bootstrap? A component behind a lazy route or a deferred block may only mount after the window has closed.
- For paginated queries, does
initialNumItemsmatch the value used during the server render?
- Configuration — what gets transferred in the first place.
- Authenticated SSR — the one case where you deliberately transfer nothing.