Stale-While-Revalidate
The Problem SWR Solves
Traditional data fetching presents a tradeoff:
- No cache: data is always current, but every navigation triggers a network request and shows a spinner.
- Permanent cache: responses are instant, but data goes stale and the UI reflects outdated information.
SWR (Stale-While-Revalidate) resolves this tradeoff.
How It Works
When fetchQuery or watchQuery is called with stale data in cache:
- Return the stale data immediately. The UI renders without a spinner.
- Dispatch a background revalidation. The fetcher runs silently.
- Update the cache and all active streams when fresh data arrives.
t=0:00 Success(user: Alice, updatedAt: 10:00) ← staleTime: 5 min
t=5:01 fetchQuery called
├── returns Alice immediately (stale data)
└── fires background fetch
t=5:02 Success(user: Alice McBride, updatedAt: 10:05) ← updated
└── watchQuery streams emit new state
Configuring Stale Time
// Default: Duration.zero: always stale, always revalidates
// (still serves cached data instantly on every call)
options: QoraOptions(staleTime: Duration.zero),
// Fresh for 5 minutes: no network call within the window
options: QoraOptions(staleTime: Duration(minutes: 5)),
// Configuration data: only revalidates once per hour
options: QoraOptions(staleTime: Duration(hours: 1)),
Choosing staleTime by data type
| Data type | Recommended staleTime |
|---|---|
| Live prices, notifications | Duration.zero |
| User profile, settings | Duration(minutes: 5) |
| Post / article content | Duration(minutes: 10) |
| Rarely-changing config | Duration(hours: 1) |
| Static reference data | Duration(days: 1) |
SWR with fetchQuery
When fetchQuery hits a stale cache entry, it returns the stale data synchronously and fires a revalidation in the background. The call resolves immediately:
// This returns immediately with cached data if available
final user = await client.fetchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
options: QoraOptions(staleTime: Duration(minutes: 5)),
);
// If data was stale:
// - `user` is the stale value (returned instantly)
// - A background fetch is already in progress
// - Active watchQuery streams will receive the updated user when the fetch completes
SWR with watchQuery
watchQuery streams are the primary interface for SWR. Subscribers receive the latest state, including the result of background revalidation:
StreamBuilder<QoraState<User>>(
stream: client.watchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
options: QoraOptions(staleTime: Duration(minutes: 5)),
),
builder: (context, snapshot) {
return switch (snapshot.data) {
// Shows stale data instantly while Loading(previousData: user) is emitted
Loading(:final previousData) when previousData != null =>
Stack(children: [
UserCard(previousData),
const LinearProgressIndicator(),
]),
Success(:final data) => UserCard(data),
Failure(:final error, :final previousData) => Column(children: [
if (previousData != null) UserCard(previousData),
ErrorBanner('$error'),
]),
_ => const CircularProgressIndicator(),
};
},
)
State sequence on mount with stale cache
Subscription starts
↓ emits immediately
Loading(previousData: Alice) ← stale data visible, subtle loading indicator
↓ fetch completes
Success(data: Alice McBride) ← fresh data, UI updates seamlessly
Refetch on Mount
By default, watchQuery triggers a fetch on the first subscription when data is stale. This behavior is controlled by refetchOnMount:
// Always refetch on mount, even if cached data is still fresh (default when
// QoraClientConfig.refetchOnMount is true)
options: QoraOptions(refetchOnMount: true),
// Only fetch if data is missing or stale; skip refetch when data is fresh
options: QoraOptions(refetchOnMount: false),
// Inherit the global QoraClientConfig.refetchOnMount setting (default)
options: QoraOptions(refetchOnMount: null),
Polling (Active Revalidation)
For live data, combine SWR with refetchInterval to continuously refresh the cache:
client.watchQuery<Stats>(
key: ['dashboard', 'stats'],
fetcher: api.getDashboardStats,
options: QoraOptions(
staleTime: Duration(seconds: 10),
refetchInterval: Duration(seconds: 30),
),
)
The interval timer starts when the first subscriber attaches, fires every refetchInterval, and stops when the last subscriber cancels. Polling does not occur for off-screen queries.
Force Refresh
To bypass the stale check and force an immediate network request, invalidate the key first:
// Force a fresh fetch on next access
client.invalidate(['users', userId]);
// Or immediately prefetch fresh data
await client.prefetch<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
options: QoraOptions(staleTime: Duration.zero),
);
Next Steps
- Deduplication: How concurrent callers share a single request
- Caching: LRU, GC, and
gcTimevsstaleTime - QoraOptions API: Full options reference
Caching
Qora's cache enables instant data access. Learn how cache entries are created, aged, and garbage collected, how structural sharing prevents unnecessary rebuilds, and how to control each behavior.
Deduplication
Qora merges concurrent requests for the same key into a single network call, regardless of the number of callers.