Fetch policies & freshness
By default sling_gql is cache-first: a field that is in the cache is rendered and never asked for again; only what is missing is fetched. That is the right default for a client whose cache is normalized — the list already fetched the launch’s name, the detail screen should not fetch it again — but not every screen wants data that could be hours old. Two knobs change that, per widget or client-wide, and they compose.
fetchPolicy
Section titled “fetchPolicy”QueryBuilder<Query>( fetchPolicy: FetchPolicy.cacheAndNetwork, builder: (context, query, state) { ... },)| Policy | First build | While the request is in flight | After it lands |
|---|---|---|---|
cacheFirst (default) |
reads the cache, fetches what is missing | skeletons for the missing fields | rebuilds with data |
cacheAndNetwork |
reads the cache and refetches the whole selection | cached data on screen, state.isLoading true |
rebuilds if anything changed |
networkOnly |
ignores the cache: everything selected is fetched | skeletons (state.isSkeleton) even for cached fields |
reads the cache from then on |
cacheAndNetwork refetches once, when the widget’s scope is created (the screen
opens). Later rebuilds — a parent setState, a tap — read the cache like cacheFirst;
otherwise every rebuild would be a request. It is the policy for “show what we have,
refresh it”: a detail screen opened from a list, a dashboard.
networkOnly is for screens that must not show cached values first — a payment
confirmation, a “current status” page. The response is written to the shared cache like any
other, so the rest of the app benefits; only this scope refuses to read stale values until
its own request has succeeded. If that request fails, the scope keeps its skeletons and
state.error (it does not fall back to the cache).
SlingClient(fetchPolicy:) sets the default for every scope; client.resolve(body, fetchPolicy: FetchPolicy.networkOnly) is the imperative “fetch this now, whatever the cache
has”.
maxAge — stale-while-revalidate
Section titled “maxAge — stale-while-revalidate”SlingClient<Query>( endpoint: ..., schema: slingSchema, maxAge: const Duration(minutes: 5), // client-wide default);
QueryBuilder<Query>( maxAge: const Duration(seconds: 30), // this widget only builder: (context, query, state) { // state.isStale: rendering data older than maxAge while it is refetched },)Every response stamps the fields it writes with the time it landed — per dependency key,
Launch:launch-181.name, the same granularity as rebuild notifications. When a build reads
a field older than maxAge (or one that was never fetched from the server: a hydrated
snapshot, an optimistic write), the widget keeps rendering the cached value,
state.isStale turns true, and the whole selection is refetched in the background. When the
response lands the widget rebuilds with fresh data and isStale goes back to false.
Freshness is a property of the data, not of the widget: if the list refreshed
Launch:launch-181.name a second ago, the detail screen opening now finds it fresh even
though it never fetched anything itself. And because it is per field, a screen that reads
one stale field alongside fresh ones revalidates its whole selection in one request.
maxAge applies on top of any fetchPolicy. Without it (the default), cached data never
expires: only refetch() gets fresh data.
Try it
Section titled “Try it”One widget showing a launch, under the policy picked at the top:
class LaunchCard extends StatelessWidget { const LaunchCard({super.key, required this.policy}); final Policy policy;
@override Widget build(BuildContext context) { return QueryBuilder<Query>( fetchPolicy: policy == Policy.cacheAndNetwork ? FetchPolicy.cacheAndNetwork : FetchPolicy.cacheFirst, maxAge: policy == Policy.maxAge ? const Duration(seconds: 5) : null, builder: (context, query, state) { final launch = query.launch(id: launchId); final name = launch?.name; final status = launch?.status; if (state.isSkeleton) return const Text('Loading…'); return Row( children: [ StatusIcon(status), const SizedBox(width: 8), Text('$name · ${status?.graphqlName}'), const Spacer(), if (state.isStale) const Text('stale, refreshing…') else if (state.isLoading) const Text('refreshing…'), ], ); }, ); }}Close the widget, tap Change it on the server (someone else updates the launch’s status), then open it again:
- cacheFirst shows the old status, with no request. The cache has an answer, so nothing is asked.
- cacheAndNetwork shows the old status at once, refreshing…, then the new one. Every opening costs a request.
- maxAge: 5 s behaves like cacheFirst within 5 seconds of the last response. Wait longer before opening: it shows the old status marked stale, and refreshes it.
revalidate() — the soft refetch
Section titled “revalidate() — the soft refetch”state.refetch() always sends a request. state.revalidate() sends one only if something
this widget read is older than its maxAge, and completes immediately otherwise — the
right thing for “the app came back to the foreground” or “this tab is visible again”, where
a hard refetch on every event would be wasteful. Without a maxAge it is exactly
refetch().
// e.g. in an AppLifecycleListener.onResumestate.revalidate();Errors
Section titled “Errors”A background refresh that fails — a cacheAndNetwork mount, a stale revalidation, a
refetch() — keeps the cached data on screen and sets state.error, which is
sticky like any other: rebuilds do not
retry it, so a failing server cannot loop, and refetch() (or
SlingClient(retryFailedAfter:)) clears it. state.isStale stays true meanwhile — the
data is still old.