Streamed Queries
Not released yet. Streamed queries ship in the upcoming 3.0. The current stable release, 2.0.0, does not have them.
A streamed query is for a response that arrives in pieces and should be shown as it grows — a server-streaming gRPC call, LLM token output, a log tail. The fetcher returns an IAsyncEnumerable of chunks, and a reducer folds each chunk into the data as it arrives.
A streamed query is a regular IQuery<TArgs, TData>. Caching, deduplication, stale time, cancellation, retries, <Suspense>, <Transition> and QueryViewModel all work as they do for any query. The only addition is the IsStreaming flag on its state.
Creating a Streamed Query
Use IQueryClient.CreateStreamedQuery:
IQuery<string, string> answer = queryClient.CreateStreamedQuery(
new StreamedQueryOptions<string, string, string>
{
KeyFactory = prompt => QueryKey.From("answer", prompt),
Fetcher = (prompt, ct) => llm.StreamTokensAsync(prompt, ct), // IAsyncEnumerable<string>
InitialValue = "",
Reducer = (text, token) => text + token,
});
answer.SetArgs("Explain stale-while-revalidate");
The three type arguments are TArgs (what identifies the stream), TData (the folded data, a reference type), and TChunk (one item the stream yields).
Required Options
KeyFactory — same as regular queries. All observers with the same key share one cache entry and one running stream.
Fetcher — opens the stream for the given args. The CancellationToken is cancelled when the query is cancelled, disposed, or superseded by a refetch, and enumeration stops as soon as it is. It is called again, from the start, on every refetch and retry.
InitialValue — the seed every fetch starts folding from: "" for text, ImmutableList<T>.Empty for a list. A stream that yields nothing completes with this value.
Reducer — folds one chunk into the data so far and returns the new data. Every intermediate value is published as a state, so return a new value rather than mutating the old one:
// ✅ immutable — every state is its own snapshot
Reducer = (rows, row) => rows.Add(row), // ImmutableList<RowDto>
// ❌ mutates the list that earlier states already hold
Reducer = (rows, row) => { rows.Add(row); return rows; }, // List<RowDto>
Optional Options
| Option | Default | Description |
|---|---|---|
RefetchMode |
Reset |
What a refetch shows while it streams — see Refetching |
StaleTime |
global | Same as for regular queries |
CacheTime |
global | Same as for regular queries |
RefetchInterval |
null |
Polls on this interval. Does not fall back to the global RefetchInterval — see below |
RetryHandler |
global | Each attempt restarts the stream from InitialValue |
Name |
first key part | Low-cardinality name for metrics |
IsEnabled |
true |
Same as for regular queries |
DataComparer |
EqualityComparer<TData>.Default |
Compares the completed data against the previous completed data |
RefetchInterval is the one option that ignores the client-wide default. Re-opening a stream is rarely cheap (an LLM call, a long gRPC stream), so a global polling interval meant for ordinary queries should not quietly restart every stream in the app.
State While Streaming
| Moment | Status |
IsStreaming |
CurrentData |
|---|---|---|---|
| Before the first chunk | Fetching |
false |
null |
| After each chunk | Success |
true |
the data folded so far |
| Stream completed | Success |
false |
the final data |
| Stream failed | Failure |
false |
null — the data received so far is in LastData |
| Cancelled | Idle |
false |
null — the data received so far is in LastData |
State emits once per chunk, and replays the current state to new subscribers, so a component that subscribes mid-stream renders the text received so far straight away.
Success and Settled emit once per stream, when it completes — not for every chunk. Use them for "the answer is done" work, and State (or the Blazor components) to render the growing data:
answer.State.Subscribe(state => Render(state.CurrentData ?? state.LastData, isTyping: state.IsStreaming));
answer.Success.Subscribe(text => SaveToHistory(text));
Back-pressure: a fast producer emits one state per chunk, with no throttling. If rendering every chunk is too much, sample the state stream where you render it —
answer.State.Sample(TimeSpan.FromMilliseconds(50))— or yield coarser chunks.QueryViewModelalready coalesces to one UI update per dispatcher hop.
Failures and Retries
If the stream throws part-way through, the query goes to Failure, and LastData holds what had arrived so far — so a chat UI can keep showing the half-finished answer next to the error. (In Replace mode, where the partial data of a refetch was never shown, LastData keeps the previous completed data instead.)
A retry handler restarts the stream from the beginning: the fold starts again at InitialValue, and the state drops back to Fetching until the new attempt's first chunk.
Refetching
What a refetch shows while the new stream runs depends on RefetchMode:
Reset(default) — the previous data is dropped as soon as the refetch starts (Fetchingwith noLastData), and the new stream is shown as it grows. This is what a chat-style UI expects.Replace— the previous data stays visible (asLastDataduringFetching, exactly like a regular query's background refetch) and is swapped for the new data when the stream completes. Chunks are folded out of sight in the meantime.
The very first fetch always shows chunks as they arrive, whichever mode is set — there is nothing to keep.
Invalidation does not restart a running stream
Invalidate() — including the client-wide Invalidate(...) calls <QueryRefreshMonitor> makes on window focus and reconnect, and RefetchInterval ticks — does not interrupt a stream that is still running. It only marks the data stale, so the next subscriber to arrive refetches it. Restarting would throw away everything received so far, for an invalidation that usually has nothing to do with the stream.
Once the stream has completed, invalidation behaves exactly as for a regular query.
Refetch() always restarts the stream: the running one is cancelled (its token fires) and a new one starts.
Cancellation
Cancel() and disposing the last owner cancel the token the fetcher received and stop enumeration. After Cancel(), the query is Idle and LastData holds what had arrived so far.
Blazor Components
<Suspense> and <Transition> work unchanged. Both render Content as soon as the first chunk arrives and re-render on every chunk:
<Transition Query="Answer">
<Content Context="text">
<p>@text</p>
</Content>
<Loading><Spinner /></Loading>
</Transition>
The Content context is the data only. For a typing indicator, subscribe to State in the page and keep IsStreaming in a field, the same way you would track any other state flag.
MVVM
QueryViewModel<TArgs, TData> exposes a bindable IsStreaming. Data grows with every chunk. CancelCommand stays enabled, and RefetchCommand disabled, until the stream completes.
Not Covered: Raw Byte Streams
A download handed to the caller as a Stream is deliberately not a streamed query. A Stream can be read once by one reader, so two subscribers of one key could not share it. Caching it would mean buffering the whole payload, and a cache entry has no owner to dispose it. Model such a download as a mutation that writes to its destination instead.