Prewarming queries
injectPrewarmQuery() opens a short-lived background subscription for a query and its
arguments. Nothing is rendered from it — the point is that the result lands in the Convex
client’s local cache, so an injectQuery() for the same query and args a moment later reads
a warm value instead of waiting for a round trip.
The natural trigger is intent: a hover, a focus, a pointerdown on a link.
Signature
Section titled “Signature”function injectPrewarmQuery<Query extends PrewarmQueryReference>( query: Query, options?: PrewarmQueryOptions<Query>,): PrewarmQueryResult<Query>;interface PrewarmQueryResult<Query extends PrewarmQueryReference> { prewarm: (args: FunctionArgs<Query>) => Promise<boolean>;}Note the shape: the query is bound once, and prewarm() is called with the arguments. One
helper covers every row in a list.
import { Component, inject } from '@angular/core';import { Router, RouterLink } from '@angular/router';import { injectPrewarmQuery } from 'convex-angular';
import { api } from './convex/api';
@Component({ selector: 'app-user-list', imports: [RouterLink], template: ` @for (id of userIds; track id) { <a [routerLink]="['/users', id]" (mouseenter)="onIntent(id)" (focus)="onIntent(id)">{{ id }}</a> <button type="button" (click)="openWhenWarm(id)">Open {{ id }} when ready</button> } `,})export class UserListComponent { private readonly router = inject(Router);
readonly userIds = ['user-1', 'user-2'];
readonly prewarmProfile = injectPrewarmQuery(api.users.getProfile, { // Hold the warm subscription long enough to cover an unhurried click. extendSubscriptionFor: 10_000, onError: (error, args) => console.warn('prewarm failed for', args.userId, error), });
// Fire-and-forget on intent: the promise is not interesting here. The // `routerLink` navigates on click by itself; this only warms the cache. onIntent(userId: string): void { void this.prewarmProfile.prewarm({ userId }); }
// Or await it to hold navigation until the cache is warm. That only works on // an element that does not navigate by itself, like this button: `routerLink` // navigates on every click, whatever another (click) handler does. `prewarm()` // resolves false on failure, expiry, or scope destruction — navigate either // way and let the routed component's own query handle it. async openWhenWarm(userId: string): Promise<void> { await this.prewarmProfile.prewarm({ userId }); await this.router.navigate(['/users', userId]); }}PrewarmQueryOptions
Section titled “PrewarmQueryOptions”| Name | Type | Default | Description |
|---|---|---|---|
injectRef |
EnvironmentInjector |
ambient injector | Injector used to create the helper outside the current injection context. Its destruction disposes every active prewarm. |
extendSubscriptionFor |
number |
5000 |
Milliseconds to keep the background subscription alive after prewarm() is called. |
onError |
(err: Error, args: FunctionArgs<Query>) => void |
— | Invoked when a background subscription fails. Receives the args that were prewarmed. |
The window is a trade-off. Too short and the cache is already cold by the time the user
clicks; too long and you hold subscriptions for rows the user never visits. The 5 second
default covers a normal hover-then-click. For a list where hovering is incidental, shorten
it; for a deliberate “open in a moment” flow, lengthen it. Values above 2,147,483,647 ms (about
24.8 days), including Infinity, are capped to that limit; the subscription is still released
when the owning scope is destroyed.
onError receiving the args matters for a shared helper: without them you cannot tell which
row’s prewarm failed.
The prewarm() contract
Section titled “The prewarm() contract”prewarm(args: FunctionArgs<Query>): Promise<boolean>;| Resolves | When |
|---|---|
true |
The background subscription received its first result. A subsequent injectQuery() for the same query and args will read the warm cache. |
false |
The subscription failed. |
false |
The extendSubscriptionFor window expired before any result arrived. |
false |
The owning scope (component or injectRef injector) was destroyed before a result arrived. |
false |
Server-side rendering — the client is disabled and prewarming is a no-op. |
The promise settles once, at whichever of those happens first. A subscription that expires
after a result already arrived leaves the promise resolved true.
The promise never rejects, so void prewarm(...) is a safe fire-and-forget call. On the
error path the subscription is disposed immediately rather than waiting out the timer.
The hover-or-intent pattern
Section titled “The hover-or-intent pattern”In the example above, onIntent() fires and forgets, and openWhenWarm() awaits the result before navigating.
Wire onIntent to mouseenter and focus on a routerLink anchor so keyboard users get the
same benefit. The link navigates on click by itself, and the cache is probably warm already.
To hold navigation until the cache is warm, call openWhenWarm from the click handler of an
element that does not navigate by itself, such as <button type="button">. Do not put it next
to routerLink: RouterLink navigates on every click, even when another (click) handler
calls preventDefault(), so the await would never delay anything. Because prewarm()
resolves false rather than rejecting, the await form degrades to a normal loading state
instead of blocking navigation.
Server-side rendering needs no guard: prewarm() short-circuits on a disabled client and
resolves false immediately, so it never registers a timer that would delay SSR stability.
Prewarming vs. the route resolver
Section titled “Prewarming vs. the route resolver”Both warm the cache before a component renders; they differ in who waits.
injectPrewarmQuery |
convexQueryResolver |
|
|---|---|---|
| Trigger | user intent, anywhere | route activation |
| Blocks navigation | no (unless you await it yourself) |
yes, until the first result |
| Bounded | yes, extendSubscriptionFor |
no timeout on resolution |
| Best for | hover preloading, speculative warming | routes that must never render empty |