Skip to main content

Prefetching

A list of posts where every row has a prefetch button: it fetches that post into the cache with nobody watching, so opening the detail within staleTime shows the post at once and sends nothing. Below the list, one key is read three ways with client.query, awaited, with revalidateIfStale and as a static read, and a last card prefetches the first page of an infinite query. Reach for this when the next screen is predictable: the product a user is about to tap in a catalogue, the device detail behind a device list, the next step of a checkout that can load while the user fills in the current one.

Live demoPrefetchingWarm the cache before the screen that needs it opens.~3 MB, runs in your browser; no server involved.

What to try​

  • Press Prefetch post 1 (the download icon on the first row). A prefetched pill appears on the row, and the post-1 debug strip shows the entry with observers=0 and fetches=1: cached, held by no widget.
  • Press Open post 1. The title is there immediately and fetches stays at 1. Go Back to list and open a post you did not prefetch: that one shows a skeleton first and costs one request.
  • Prefetch the same post twice within ten seconds: the second press is a no-op and fetches stays put. Wait more than ten seconds and press again, and it fetches.
  • In Imperative reads, press Read (await), then Increment on the server, then Read (revalidateIfStale): returned shows the old counter on the spot while cached moves to the new one when the background fetch lands. Read (static) hands back what is cached and requests does not move.
  • Press Prefetch the first page in the last card: pages=1, rows=10, and a second press within five minutes sends nothing.

The code​

The detail and the prefetch share a key and a staleTime, which is what lets the detail find the entry fresh. The prefetch uses the cache-layer QueryOptions, since nothing observes it, and says RetryPolicy.never explicitly.

examples/showcase/lib/features/prefetching/prefetching_screen.dart · lines 81–86
QueryObserverOptions<Post> postQuery(ShowcaseApi api, int id) =>
QueryObserverOptions<Post>(
queryKey: ShowcaseKeys.post(id),
queryFn: (context) => api.post(id, signal: context.signal),
staleTime: postStaleTime,
);
examples/showcase/lib/features/prefetching/prefetching_screen.dart · lines 91–96
QueryOptions<Post> postPrefetch(ShowcaseApi api, int id) => QueryOptions<Post>(
queryKey: ShowcaseKeys.post(id),
queryFn: (context) => api.post(id, signal: context.signal),
staleTime: postStaleTime,
retry: RetryPolicy.never,
);

A prefetch is client.query(options) with the future ignored. There is no separate prefetch method: when the entry is fresh the call returns the cached data without a request, and a failure lands in the cache, not in the widget.

examples/showcase/lib/features/prefetching/prefetching_screen.dart · lines 168–174
void _prefetch(int id) {
final api = ShowcaseScope.apiOf(context);
// The future is the prefetch's only handle, and nobody wants it: a
// refused prefetch is the cache's business, not the screen's.
QueryClientProvider.of(context).query(postPrefetch(api, id)).ignore();
setState(() => _watched = id);
}

The same call awaited is an imperative read. With revalidateIfStale: true it returns what the cache holds at once and refreshes a stale entry behind it; with nothing cached it awaits the fetch, and only then can it fail. Under StaleTime.static the entry is never stale, so a cached value comes back with no request at all.

examples/showcase/lib/features/prefetching/prefetching_screen.dart · lines 113–120
/// The same read declared static. `StaleTime.static` is never stale, so a
/// cached entry is handed straight back and no request is made — not even the
/// background one `revalidateIfStale` would otherwise start.
QueryOptions<int> counterStaticRead(ShowcaseApi api) => QueryOptions<int>(
queryKey: counterKey,
queryFn: (context) => api.counter(signal: context.signal),
staleTime: StaleTime.static,
);
The whole screen
examples/showcase/lib/features/prefetching/prefetching_screen.dart
/// Upstream's `prefetching` example: the posts list, each row with a
/// prefetch button that warms the cache for the detail before it opens, and
/// an open button that then reads it — within `staleTime` — without a
/// request. The list and the detail are both `QueryBuilder`s.
///
/// The prefetch is `client.query(options).ignore()`: upstream's
/// `prefetchQuery` is folded into `query`, and ignoring the
/// future is what makes it a prefetch. Upstream prefetches on hover; here it
/// is a button, because hover never reaches a `MouseRegion` through Flutter
/// web's semantics overlay. A row whose post is in the cache shows a
/// `prefetched` pill, rebuilt on the cache's own events like the debug
/// strips. The prefetch options say `retry: RetryPolicy.never` out loud: the
/// imperative path makes one attempt unless a retry is configured, and a
/// refused prefetch must be one request, not four, for a reader counting
/// them.
///
/// The last card contrasts the three imperative reads of one key, the
/// backend's counter. `client.query(options)` awaits the fetch whenever the
/// entry is stale and hands back the new value. `client.query(options,
/// revalidateIfStale: true)` hands back what the cache holds on the spot and
/// refreshes behind it, so the value it returns is the old one for as long as
/// the fetch takes; it fails only when nothing at all is cached. The same
/// call under `staleTime: StaleTime.static` returns the cached value and
/// makes no request, background one included: a static entry is never stale.
/// `Increment on the server` moves the counter without touching the cache,
/// which is what makes a cached answer tell itself apart from a fresh one by
/// its value alone.
///
/// The fourth card is the infinite twin: `client.infiniteQuery(options)` is
/// to an infinite query what `client.query` is to a plain one — the same
/// rules, the same `.ignore()` for a prefetch — and it fetches the *first*
/// page under the key, held by nobody. That is upstream's
/// `prefetchInfiniteQuery`, folded in the same way.
///
/// Proofs (widget tests in `test/features/prefetching_test.dart`, end-to-end
/// in `e2e/tests/prefetching.spec.ts`): a prefetch is one request and marks
/// the row with nobody observing the entry; opening the prefetched post costs
/// no request and shows the title at once; opening an unprefetched post costs
/// one; a second prefetch within `staleTime` is a no-op and a third after it
/// fetches again; a refused prefetch throws nothing into the UI, leaves the
/// row unmarked, and the post opens normally afterwards; a stale read with
/// `revalidateIfStale` returns the old value on the frame of the tap while
/// the entry is fetching and the cache holds the new one once the answer
/// lands, the plain read returns the new value, and the static read makes no
/// request at all; and an infinite prefetch is one request for the first
/// page, cached with `observers=0`, and a second press within `staleTime` is
/// a no-op.
library;

import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';

import '../../shared/api.dart';
import '../../shared/cache_listener.dart';
import '../../shared/chrome.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';

const Feature prefetchingFeature = Feature(
id: 'prefetching',
title: 'Prefetching',
summary: 'Warm the cache before the screen that needs it opens.',
upstream: 'prefetching',
);

/// How long a post counts as fresh, for the prefetch and the detail alike:
/// the whole point is that the open within this window costs nothing.
const StaleTime postStaleTime = StaleTime.duration(Duration(seconds: 10));

QueryObserverOptions<List<Post>> postsQuery(ShowcaseApi api) =>
QueryObserverOptions<List<Post>>(
queryKey: ShowcaseKeys.posts,
queryFn: (context) => api.posts(signal: context.signal),
);

/// The detail's options: an observer's, since a `QueryBuilder` reads them.
QueryObserverOptions<Post> postQuery(ShowcaseApi api, int id) =>
QueryObserverOptions<Post>(
queryKey: ShowcaseKeys.post(id),
queryFn: (context) => api.post(id, signal: context.signal),
staleTime: postStaleTime,
);

/// The prefetch's options: the plain cache-layer kind, since nothing observes
/// this fetch. Same key and same `staleTime` as [postQuery], so the detail
/// finds the entry fresh; no retries, so a refused prefetch is one request.
QueryOptions<Post> postPrefetch(ShowcaseApi api, int id) => QueryOptions<Post>(
queryKey: ShowcaseKeys.post(id),
queryFn: (context) => api.post(id, signal: context.signal),
staleTime: postStaleTime,
retry: RetryPolicy.never,
);

/// The key the imperative-read card reads. Its own, not one of
/// [ShowcaseKeys]: the counter is a value a button can move on the server
/// behind the cache's back, which is what makes "cached" and "fresh" tell
/// themselves apart by the number alone.
final QueryKey counterKey = QueryKey(const <Object?>['prefetching', 'counter']);

/// The counter read: always stale, so the plain `client.query` fetches on
/// every press and `revalidateIfStale` always has a refresh to run behind
/// the cached answer it returns.
QueryOptions<int> counterRead(ShowcaseApi api) => QueryOptions<int>(
queryKey: counterKey,
queryFn: (context) => api.counter(signal: context.signal),
staleTime: StaleTime.zero,
);

/// The same read declared static. `StaleTime.static` is never stale, so a
/// cached entry is handed straight back and no request is made — not even the
/// background one `revalidateIfStale` would otherwise start.
QueryOptions<int> counterStaticRead(ShowcaseApi api) => QueryOptions<int>(
queryKey: counterKey,
queryFn: (context) => api.counter(signal: context.signal),
staleTime: StaleTime.static,
);

/// The infinite prefetch's key: this screen's own, so the paging screens'
/// entries are untouched by it.
final QueryKey projectsPrefetchKey =
QueryKey(const <Object?>['prefetching', 'projects']);

/// The first page of the projects, as an [InfiniteQueryOptions] — the paging
/// fields plus the cache-layer ones a plain [QueryOptions] takes. `retry:
/// never` for the reason [postPrefetch] gives; fresh for five minutes, so a
/// second prefetch is a no-op.
InfiniteQueryOptions<ProjectSlice, int> projectsPrefetch(ShowcaseApi api) =>
InfiniteQueryOptions<ProjectSlice, int>(
queryKey: projectsPrefetchKey,
initialPageParam: 0,
pageFn: (context) => api.projectsFrom(
context.pageParam,
limit: 10,
signal: context.signal,
),
getNextPageParam: (page, _, __, ___) => page.nextId,
retry: RetryPolicy.never,
staleTime: const StaleTime.duration(Duration(minutes: 5)),
);

class PrefetchingScreen extends StatefulWidget {
const PrefetchingScreen({super.key});


State<PrefetchingScreen> createState() => _PrefetchingScreenState();
}

class _PrefetchingScreenState extends State<PrefetchingScreen> {
/// The post whose detail is open, if any.
int? _selected;

/// The post the second debug strip watches: the last one prefetched or
/// opened. A prefetch has no screen of its own, so this is where a reader
/// sees its entry land with `observers=0`.
int? _watched;

/// What the last imperative read was and what it handed back, plus how
/// often the server's counter has been moved behind the cache's back.
String _lastRead = 'none';
int? _returned;
bool _readFailed = false;
int _increments = 0;

void _prefetch(int id) {
final api = ShowcaseScope.apiOf(context);
// The future is the prefetch's only handle, and nobody wants it: a
// refused prefetch is the cache's business, not the screen's.
QueryClientProvider.of(context).query(postPrefetch(api, id)).ignore();
setState(() => _watched = id);
}

void _open(int id) => setState(() {
_selected = id;
_watched = id;
});

void _back() => setState(() => _selected = null);

/// The infinite twin of [_prefetch]: the first page, fetched and cached
/// with nobody observing it, the future ignored.
void _prefetchProjects() {
final api = ShowcaseScope.apiOf(context);
QueryClientProvider.of(context)
.infiniteQuery(projectsPrefetch(api))
.ignore();
}

/// The infinite prefetch, and what the cache holds under its key — read on
/// every cache event, like the imperative-read card, because nothing
/// observes the entry.
Widget _infinitePrefetchCard() => SectionCard(
title: 'An infinite prefetch',
child: SemanticsGroup(
name: 'infinite prefetch',
child: CacheListener(
builder: (context) {
final cached = QueryClientProvider.of(context)
.getInfiniteQueryData<ProjectSlice, int>(projectsPrefetchKey);
final rows = cached?.pages
.fold<int>(0, (n, page) => n + page.items.length) ??
0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'client.infiniteQuery(options).ignore() is to an '
'infinite query what client.query is to a plain one: '
'the first page, fetched and cached with nobody '
'observing it — upstream\'s prefetchInfiniteQuery. A '
'second press within staleTime is a no-op.',
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _prefetchProjects,
child: const Text('Prefetch the first page'),
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
children: <Widget>[
for (final fact in <String>[
'pages=${cached?.pages.length ?? 0}',
'rows=$rows',
])
Text(
fact,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
],
);
},
),
),
);

Future<void> _read(
String label, {
required bool revalidateIfStale,
required bool neverStale,
}) async {
final api = ShowcaseScope.apiOf(context);
final client = QueryClientProvider.of(context);
final options = neverStale ? counterStaticRead(api) : counterRead(api);
setState(() {
_lastRead = label;
_returned = null;
_readFailed = false;
});
try {
final value =
await client.query(options, revalidateIfStale: revalidateIfStale);
if (mounted) {
setState(() => _returned = value);
}
} on Object {
// The plain call fails whenever its fetch does; with
// `revalidateIfStale` only an empty cache can fail. Either way the
// card says so rather than leaving an error to the zone.
if (mounted) {
setState(() => _readFailed = true);
}
}
}

/// Moves the counter on the server and leaves the cache alone, so the
/// cached value is provably out of date and a read's answer says which of
/// the two it is.
Future<void> _incrementOnServer() async {
final api = ShowcaseScope.apiOf(context);
try {
await api.increment();
if (mounted) {
setState(() => _increments += 1);
}
} on Object {
// A refused increment is not this card's subject; the reads are.
}
}

/// The three calls side by side, with what came back and whether a request
/// was made. `cached` and `requests` are read on every cache event, the way
/// the strips are: the background refresh has no observer to announce it.
Widget _readsCard() => SectionCard(
title: 'Imperative reads',
child: SemanticsGroup(
name: 'reads',
child: CacheListener(
builder: (context) {
final client = QueryClientProvider.of(context);
final cached = client.getQueryData<int>(counterKey);
final requests =
ShowcaseScope.of(context).stats.fetchesOf(counterKey);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
FilledButton(
onPressed: () => _read(
'await',
revalidateIfStale: false,
neverStale: false,
),
child: const Text('Read (await)'),
),
FilledButton.tonal(
onPressed: () => _read(
'revalidate',
revalidateIfStale: true,
neverStale: false,
),
child: const Text('Read (revalidateIfStale)'),
),
OutlinedButton(
onPressed: () => _read(
'static',
revalidateIfStale: true,
neverStale: true,
),
child: const Text('Read (static)'),
),
OutlinedButton(
onPressed: _incrementOnServer,
child: const Text('Increment on the server'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 2,
children: <Widget>[
for (final fact in <String>[
'read=$_lastRead',
if (_readFailed)
'returned=failed'
else
'returned=${_returned ?? '–'}',
'cached=${cached ?? '–'}',
'requests=$requests',
'increments=$_increments',
])
Text(
fact,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
],
);
},
),
),
);


Widget build(BuildContext context) {
final api = ShowcaseScope.apiOf(context);
final selected = _selected;
final watched = _watched;

return FeatureScaffold(
feature: prefetchingFeature,
children: <Widget>[
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Notice(
'Prefetching warms the cache for a screen that has not opened '
'yet. Within staleTime (10 s) opening the post costs no request.',
),
),
if (selected == null)
_PostList(
options: postsQuery(api),
onPrefetch: _prefetch,
onOpen: _open,
)
else
_PostDetail(
id: selected,
options: postQuery(api, selected),
onBack: _back,
),
QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'),
if (watched != null)
QueryDebugStrip(
queryKey: ShowcaseKeys.post(watched),
label: 'post-$watched',
),
_readsCard(),
QueryDebugStrip(queryKey: counterKey, label: 'counter'),
_infinitePrefetchCard(),
QueryDebugStrip(queryKey: projectsPrefetchKey, label: 'projects'),
],
);
}
}

class _PostList extends StatelessWidget {
const _PostList({
required this.options,
required this.onPrefetch,
required this.onOpen,
});

final QueryObserverOptions<List<Post>> options;
final void Function(int id) onPrefetch;
final void Function(int id) onOpen;


Widget build(BuildContext context) => QueryBuilder<List<Post>>(
options: options,
builder: (context, posts) => SectionCard(
title: 'Posts',
trailing: posts.isFetching ? const Pill('refreshing') : null,
child: switch (posts) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(),
SizedBox(height: 8),
SkeletonBox(),
SizedBox(height: 8),
SkeletonBox(),
],
),
QueryError(:final error, staleData: null) =>
Notice('$error', error: true),
QuerySuccess(:final data) ||
QueryError(staleData: final data!) =>
CacheListener(
builder: (context) {
final client = QueryClientProvider.of(context);
// Bounded and scrolling on its own, so the debug strips
// under the card stay in view whatever the list's length:
// a card of thirty rows would push them off the screen,
// and a test only reads what is on it.
return SizedBox(
height: 200,
child: ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final post = data[index];
return _PostRow(
post: post,
// What upstream's bold marker reads too: the cache,
// not a flag the screen keeps — the entry may also
// have come from an open, or be gone by gcTime.
prefetched: client.getQueryData<Post>(
ShowcaseKeys.post(post.id),
) !=
null,
onPrefetch: () => onPrefetch(post.id),
onOpen: () => onOpen(post.id),
);
},
),
);
},
),
},
),
);
}

class _PostRow extends StatelessWidget {
const _PostRow({
required this.post,
required this.prefetched,
required this.onPrefetch,
required this.onOpen,
});

final Post post;
final bool prefetched;
final VoidCallback onPrefetch;
final VoidCallback onOpen;


Widget build(BuildContext context) => SemanticsGroup(
// A group per row, so a test can tie the pill to its post; explicit
// children keep the texts and buttons findable on their own.
name: 'post ${post.id}',
child: Row(
children: <Widget>[
Expanded(child: Text('${post.id} · ${post.title}')),
if (prefetched) ...<Widget>[
const Pill('prefetched'),
const SizedBox(width: 4),
],
IconButton(
tooltip: 'Prefetch post ${post.id}',
onPressed: onPrefetch,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.download_outlined),
),
IconButton(
tooltip: 'Open post ${post.id}',
onPressed: onOpen,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.chevron_right),
),
],
),
);
}

class _PostDetail extends StatelessWidget {
const _PostDetail({
required this.id,
required this.options,
required this.onBack,
});

final int id;
final QueryObserverOptions<Post> options;
final VoidCallback onBack;


Widget build(BuildContext context) => QueryBuilder<Post>(
options: options,
builder: (context, post) => SectionCard(
title: 'Post #$id',
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (post.isFetching) const Pill('refreshing'),
IconButton(
tooltip: 'Back to list',
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
),
],
),
child: switch (post) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(height: 20, width: 240),
SizedBox(height: 8),
SkeletonBox(),
SizedBox(height: 4),
SkeletonBox(),
],
),
QueryError(:final error, staleData: null) =>
Notice('$error', error: true),
QuerySuccess(:final data) ||
QueryError(staleData: final data!) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (post case QueryError(:final error)) ...<Widget>[
Notice('Refetch failed: $error', error: true),
const SizedBox(height: 8),
],
Text(
data.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(data.body),
],
),
},
),
);
}