Core Concepts

Stale-While-Revalidate

Qora SWR returns cached data immediately and revalidates in the background to keep UIs responsive and data current.

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:

  1. Return the stale data immediately. The UI renders without a spinner.
  2. Dispatch a background revalidation. The fetcher runs silently.
  3. 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

user_repository.dart
// 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 typeRecommended staleTime
Live prices, notificationsDuration.zero
User profile, settingsDuration(minutes: 5)
Post / article contentDuration(minutes: 10)
Rarely-changing configDuration(hours: 1)
Static reference dataDuration(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:

user_repository.dart
// 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:

user_page.dart
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:

main.dart
// 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:

dashboard_page.dart
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:

main.dart
// 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

Copyright © 2026