Skip to content

Caching

There is no “response object” in sling_gql. Widgets read the cache, always. A fetch is just a way of filling it. This is what makes optimistic updates trivial and what makes normalization powerful — but it also means you should understand its shape.

Shape: entities, with the roots as entities too

Section titled “Shape: entities, with the roots as entities too”

The cache is a flat map of entities. Every object the response contains that has a __typename and an id is stored once, under Typename:id, and referenced from wherever it appeared. Objects without an id (Stats, PageInfo here) stay inline in their parent. The operation roots are entities like any other (ROOT_QUERY, ROOT_MUTATION):

client.cache.snapshot
{
"ROOT_QUERY": {
"company": { "__ref": "Company:company-1" },
"stats": { "__typename": "Stats", "totalLaunches": 181, "successRatePct": 89.8 },
"launches_c0y93u18lig6v": {
"__typename": "LaunchConnection",
"nodes": [ { "__ref": "Launch:launch-181" }, { "__ref": "Launch:launch-180" } ],
"pageInfo": { "__typename": "PageInfo", "hasNextPage": true, "endCursor": "Y3Vyc29yOjE5" },
"totalCount": 181
},
"launch_1ov936k1qddray": { "__ref": "Launch:launch-181" }
},
"Launch:launch-181": {
"__typename": "Launch", "id": "launch-181", "name": "Crew-5",
"rocket": { "__ref": "Rocket:starship" }, "details": "…"
},
"Rocket:starship": { "__typename": "Rocket", "id": "starship", "name": "Starship" },
"Company:company-1": { "__typename": "Company", "id": "company-1", "name": "Sling Space", "ceo": "Alex Corren" }
}

Field keys are still the selection alias (field name + hash of the arguments), so a response merges without any lookup table. Every Accessor is a (selection node, cache path) pair — ["launches_c0y93u18lig6v", "nodes", 0, "rocket"] for the rocket of the first row. read walks the path and derefs a Ref transparently whenever it meets one; the generated code never sees references. A path may also start with a Ref to address an entity directly: ["Launch:launch-181" as Ref, "name"].

Normalization only works if id is actually in the response. The generator knows which object types have an id field and emits keyed: true on every field returning one (object('rocket', Rocket.new, keyed: true)). The runtime then prints id next to __typename in every such selection — you never select it by hand:

launches(first: $first) {
__typename
nodes {
__typename
id # added automatically: Launch is keyed
name
}
}

The key field comes from the generator: --key-field uuid decides which types are keyed and is recorded in the generated slingSchema, so SlingClient(schema: slingSchema) builds its cache with Normalization(keyField: 'uuid') — nothing to keep in sync by hand. Passing your own cache: next to schema:? Build it with Cache(normalization: slingSchema.normalization); a mismatching key field fails an assert. Normalization(identify: (obj) => …) replaces the whole rule; Normalization.none turns it off: every object is then stored where it appears, by path.

An identify that reads more than the key field (an id only unique per owner, keyed Item:<id>:<ownerId>) declares those fields on the type, so that every request selects them, one fetching only the fields a screen lacks included:

SlingClient<Query>(
schema: slingSchema,
cache: Cache(normalization: normalization),
typePolicies: const {Item: TypePolicy(keyFields: ['ownerId'])},
);

Lookups: launch(id:) is served from the entity

Section titled “Lookups: launch(id:) is served from the entity”

A root field whose only argument is the key and which returns a keyed type (launch(id: ID!): Launch) is emitted with lookup: 'Launch'. When launch_1ov936k1qddray is not in ROOT_QUERY yet but Launch:launch-181 exists, the accessor is redirected to the entity. Fields the entity already has are read from cache; fields it lacks miss individually and are fetched — only those:

Detail screen opened after the list
query ($id: ID!) {
launch_1ov936k1qddray: launch(id: $id) {
__typename
id
details # not in the list → fetched
flightNumber # not in the list → fetched
# name, date, status, rocket { name } came from the list's entities
}
}

That is the “no double fetch across paths” property; example/test/app_test.dart asserts it on the real request.

Writes: entities merge, lists are replaced

Section titled “Writes: entities merge, lists are replaced”

Responses are merged, not replaced, at the entity level: widget A fetching me { name } and widget B fetching me { age } end up with one User:1 holding both. A list of references is replaced wholesale by the incoming list (there is nothing to merge element-wise — the elements themselves merge as entities). Inline objects deep-merge as before, and inline lists still merge by index.

Entities a replaced list no longer references (or that only an evicted parent or a mutation’s result pointed to) stay in the cache until a sweep. The client runs one by itself every 100 server responses, the next time it is idle: client.gc() removes everything unreachable from the roots except the entities a live widget reads — a detail screen served from launch(id:) keeps its Launch even after the list that brought it moved on. It returns the keys it dropped, and never runs while a mutation is in flight (a rollback could bring references back).

SlingClient(schema: slingSchema, gcAfterWrites: 500); // sweep less often
SlingClient(schema: slingSchema, gcAfterWrites: null); // only when you call client.gc()
// e.g. sweep when the app goes to the background:
final listener = AppLifecycleListener(onPause: client.gc);

cache.gc() is the raw version: it only keeps what the roots reach (plus any retain: keys you pass), whatever is on screen.

Every scalar field has a generated setter that writes to the cache:

launch.name = '${launch.name} ✨';

Because there is one Launch:launch-181, setting name on the detail screen updates the list row too. Inside a mutation’s optimistic: callback such writes are journaled and rolled back if the mutation fails — see Mutations.

cache.read('query', ['launches_c0y93u18lig6v', 'nodes', 0]) works, but nobody can type a hashed alias. Outside a widget build — in an optimistic: callback, after await client.mutate(...), in a push-notification handler — use the typed scope instead:

final cache = client.cacheScope; // SlingScope.of<Query>(context).cacheScope
final launch = cache.launch('launch-181'); // Launch? — the cached entity, or null
launch?.favorite = true; // ordinary generated setter
final name = cache.query.me?.name; // typed root fields, from the cache only

client.cacheScope returns a CacheScope<Query>, a recorder like the one behind every QueryBuilder, with two differences: reads never fetch (a field that is not cached reads as null, an object as a skeleton, and nothing is sent) and it never rebuilds (it records no dependencies). Writes go through the normal write path: every scope that read the field rebuilds, and inside a mutation’s optimistic callback they are journaled and rolled back on failure, exactly like setters on accessors you got from a build.

The generator emits one method per keyed type (extension SlingCacheAccess on CacheScope<Query>):

lib/generated/schema.dart
extension SlingCacheAccess on CacheScope<Query> {
Launch? launch(String id) => entity('Launch', id, Launch.new);
Rocket? rocket(String id) => entity('Rocket', id, Rocket.new);
// …
}

entity(typename, id, ctor) resolves the key through Normalization.lookup — launch('launch-181') addresses Launch:launch-181 — and returns null when that entity is not in the cache: never a skeleton, never a request. The accessor it returns is the ordinary generated Launch, starting at the entity (path == [Ref('Launch:launch-181')]), so everything reachable from it (launch.rocket?.name) reads the same way.

A response replaces a list wholesale, but a mutation’s response rarely contains the list it changed. cacheScope.list(selector) addresses one cached list field — by reading it, so the arguments hash to the same alias as in the widget — and edits its refs:

final cache = client.cacheScope;
final favorites = cache.list((q) => q.me?.favorites); // CacheList<Launch>
favorites.prepend(cache.launch('launch-181')!); // also: append, remove, contains
cache.evict(cache.launch('launch-180')!); // gone from every list

Edits write the whole new list back (Viewer:viewer-1.favorites is the touched key, exactly as if a response had replaced it), are journaled like any CacheWrite inside a mutation’s optimistic callback, and are set-like no-ops on a list that is not cached. Each argument set is its own entry — launches(filter: …) lists are separate — and the edit applies to the one you name; with RelayStylePagination the pages of one connection are one list, edited as a whole. See Updating lists after a mutation for the full rules.

cache.evict(entity) (or the untyped client.cache.evict('Launch:launch-181')) removes the entity, drops it from every list that referenced it, and blanks object fields that pointed at it so they read as missing and get re-fetched on the next build. The typed form also notifies every scope that read one of those fields.

List rules: lists that follow their entities

Section titled “List rules: lists that follow their entities”

cacheScope.list is imperative: you know which list changed. Often the list is a function of the entity — launches(filter: {status}) holds a launch iff its status matches, me.favorites holds a launch iff launch.favorite — and then a rule can do it, whichever way the entity changed (a mutation response, an optimistic setter, a subscription event):

SlingClient<Query>(
listRules: [
ListRule<Launch>(
field: 'launches', items: 'nodes', // a connection: the list is under `nodes`
typename: 'Launch', ctor: Launch.new,
belongs: (args, launch) {
final status = (args['filter'] as Map?)?['status'];
return status == null || launch.status?.graphqlName == status;
},
position: ListPosition.prepend,
),
ListRule<Launch>(
field: 'favorites', typename: 'Launch', ctor: Launch.new,
belongs: (_, launch) => launch.favorite == true,
),
],
)

Whenever a Launch entity is written, the client re-evaluates belongs for every cached list under a launches / favorites node it has sent — it remembers each alias’s arguments, so the “Success” segment you visited earlier counts even though nothing is showing it — and adds or removes the reference. Lists that were never fetched are left alone. Edits go through the write path: dependants rebuild, and inside optimistic they are journaled and rolled back with the mutation.

Two rules of the rules:

  • Query responses only remove. Without a merging policy each page is its own list, and the launches of page two “belong” to launches(first: 20) as much as page one’s do — yet must not be added to it. So a response can make an entity leave lists it no longer belongs to (the cached “Scheduled” segment loses a launch a fresh response shows as SUCCESS), but entities only join lists on what the app or the server pushes: mutation responses, subscription events, setters. A response’s own lists are the server’s word.
  • Decide on fields every reader selects. belongs reads through a non-fetching accessor: a field that is not cached reads null, and a rule on it would evict the entity. status is on every row; upcoming is not.

Membership is set-like; the position of a joining entity is prepend/append, and totalCount-style siblings are not maintained. The mission-control flow of the example app runs on two such rules.

Every read records the dependency keys it traversed — ROOT_QUERY.launches_c0y93u18lig6v, Launch:launch-181.name — on the scope that made it (entity.field, see depKey). Every write returns the keys it touched. A scope rebuilds when the two intersect, so:

  • a mutation returning Launch:launch-181 { status } rebuilds the list row and the detail screen, but not a widget that only read the launch’s name;
  • the detail screen’s fetch does not rebuild the header that reads company.

A key is touched only when its value changed. That includes inline objects and lists (stats, pageInfo, a connection without an id): they are compared structurally as they are merged, so a refetch that returns the same pageInfo rebuilds nobody who reads it. A changed field inside a referenced entity touches that entity’s key (Launch:launch-181.name), not the list holding the reference.

A list of objects without an id (Launch.links, say) held by an entity field is split further. A read through element 2 (launch.links[2].url) records Launch:launch-181.links[2], and launch.links itself records only the list’s length, Launch:launch-181.links[length]. A response or a setter changing the url of one link touches links and links[2], so only the readers of that element rebuild; a link added or removed touches links[length] too (and the elements that went away or shifted).

  • A list nested inside an element (links[2].parts) is part of that element.
  • Lists of entities keep depending on the whole field: their elements change by themselves (Launch:launch-182.name), and a reordering rebuilds every reader of the list.
  • Lists of scalars (tags) are read whole, so their readers depend on the whole field.

A scope that calls the list getter depends on the length either way; what gains is a SlingRow per element, whose only key is its element’s.

The unit of rebuilding is the scope, and a QueryBuilder is one scope. Rows that are plain child widgets record their reads there too, so a write to one launch rebuilds the whole list — every visible row included. Wrap the row in a SlingRow to give it its own dependency set:

SliverList.builder(
itemCount: launches.length,
itemBuilder: (context, i) => SlingRow(
launches[i],
ctor: Launch.new, // the generated constructor tear-off
builder: (context, launch) => LaunchTile(
name: launch.name,
favorite: launch.favorite ?? false,
),
),
)

The row still fetches through the list’s request (misses go to the enclosing QueryBuilder, which also keeps the loading and error state), but the keys it reads (Launch:launch-181.favorite) are its own: toggling a favourite, a subscription event or an optimistic setter rebuilds that row only. The list keeps depending on the list itself, so adding and removing rows works as before. Read the row’s fields through the accessor the builder receives; reads through the parent’s copy still land on the parent. The example’s LaunchRow is built this way.

SlingRow is worth it for long lists and busy entities (live status updates); a short screen that rebuilds as a whole is cheaper to write and just as fast.

Five launches, each row counting its builds, and a detail card for the first one:

example/lib/demos/caching_demo.dart
class LaunchList extends StatelessWidget {
const LaunchList({super.key, required this.slingRows, required this.builds});
final bool slingRows;
final Map<String, int> builds;
@override
Widget build(BuildContext context) {
return QueryBuilder<Query>(
builder: (context, query, state) {
final launches = query.launches(first: 5)?.nodes ?? const <Launch>[];
return ListView(
children: [
for (final launch in launches)
slingRows
// Its own dependencies: a write to this launch rebuilds
// this row only.
? SlingRow(
launch,
ctor: Launch.new,
builder: (context, launch) => LaunchTile(launch, builds),
)
// Reads land on the list's scope: any write to any row
// rebuilds the whole list.
: LaunchTile(launch, builds),
],
);
},
);
}
}
example/lib/demos/caching_demo.dart
class DetailCard extends StatelessWidget {
const DetailCard({super.key});
@override
Widget build(BuildContext context) {
return QueryBuilder<Query>(
builder: (context, query, state) {
// `launch(id:)` is a lookup: it finds the Launch:launch-181 the list
// already stored, so reading its name costs no request.
final name = query.launch(id: _firstId)?.name;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: kColorSurface,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Detail: ${name ?? '…'}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
);
},
);
}
}
  • Open the first launch: the detail shows its name with no request. The list stored Launch:launch-181, and launch(id:) found it.
  • Rename it writes launch.name into the cache (a setter, as an optimistic update would). The row and the detail change together, still no request.
  • With SlingRow, only the renamed row’s count goes up. Switch to Plain rows and rename again: every row is rebuilt, because their reads belong to the list’s scope.

By default every argument set of a field is its own cache entry and a response replaces it. SlingClient(typePolicies:) changes that per field — app configuration, not generated code — keyed by the generated accessor class the field is read through and the field name:

SlingClient<Query>(
typePolicies: {
Query: TypePolicy(fields: {
'launches': RelayStylePagination(), // pages → one growing list
'search': FieldPolicy(keyArgs: ['text']), // `limit` shares the entry
}),
Rocket: TypePolicy(fields: {'launches': RelayStylePagination()}),
},
)
  • keyArgs are the arguments that identify the entry; the others do not. Each request still prints the field under an alias of all its arguments (two pages in one document never clash) and the response is mapped back to the entry, as for fragment fields.
  • RelayStylePagination merges cursor pages into one list (nodes, edges, pageInfo), with first/after/last/before out of the key; a page not merged yet reads as a miss, a refresh starts the list over. The pagination guide has the details.
  • The key is the exact class. A field read through an interface accessor (Node) and through a member’s asTeam are two lookups: register the policy under each class the field is read through.

The merged value is an ordinary cache value: the merge runs per page, then the result is merged into the field like any response, so only what changed touches its readers (an identical refresh rebuilds nothing), fetchedAt, changesSince / compact, gc, optimistic rollbacks, list rules and SQLite persistence see one field. A relay entry also records which pages it holds (__pages, a key no GraphQL field can have), which is how it tells a merged page from one to fetch, after a restart too.

FieldPolicy(merge:) gets the cached value (null when absent), one page of the response and the arguments it was sent with — in the cache’s own format: entities are refs (Ref from package:sling_gql/internal.dart, equal when their keys are), inline objects maps. Return a new value; do not mutate existing:

'tags': FieldPolicy(
keyArgs: const [], // every page in one entry
merge: (existing, incoming, context) => [
...?existing as List?,
...incoming! as List,
],
),

A merge alone never fetches a page: with every argument set sharing the entry, any read is served from it. For pages of your own (offsets, say), subclass FieldPolicy and override covers(existing, args) (does the entry hold what these arguments ask for? false is a miss, fetched with them), pageArgs (dropped by a refresh, so it starts over) and pages(existing) (what to refetch when a row reads a new field) — RelayStylePagination is that class for cursors.

Reads are synchronous — they happen inside build() — so the store is in memory and stays in this package. To keep it across launches in SQLite, use sling_gql_sqflite: it is built on the hooks below. What a persistence layer (sqlite, hive, a file — as separate packages) needs is exposed and deliberately small:

  • cache.snapshot — a JSON-able deep copy of every entity (refs as {"__ref": key}), and Cache(initial: json) to hydrate it back;
  • cache.onChange — a Stream<Set<String>> of the dependency keys (entity.field) a change touched. It fires once per change: a response, a mutation, a subscription event or an optimistic callback is one change, list-rule edits and an automatic gc included. Several manual writes become one with cache.batch(() { ... }). Nothing fires when a refetch brings the same data;
  • cache.version and cache.changesSince(version) — a counter bumped once per change, and the entities changed or removed since an earlier value of it, as a CacheDelta: JSON copies of those entities only (changed), the keys to delete (removed), and the version to ask from next time. The operation roots (ROOT_QUERY, …), which hold one field per query, come field by field instead (changedFields, removedFields), so a save does not grow with the session; delta.applyTo(json) applies all of it to a stored snapshot. cache.compact(upTo: version) tells the cache a store holds everything up to version: it drops those change records (deltas from older versions are then full). A hydrated cache starts at version 0: what it was hydrated from is the persisted baseline.

So a write-behind adapter debounces on onChange and stores deltas, never the whole cache (the sketch below is the shape of what sling_gql_sqflite does, minus its per-root-field rows, bounds and invalidation). Saves run one after another: a slow save must not land after a newer one and put older copies back on disk.

final stored = await store.readAll(); // the JSON a previous run persisted
final cache = Cache(initial: stored);
var persisted = cache.version; // 0: `stored` is up to date
Future<void> save() async {
final delta = cache.changesSince(persisted);
if (delta.isEmpty) return;
if (delta.full) await store.deleteAll();
await store.delete(delta.removed);
await store.putAll(delta.changed);
// Roots: merge the changed fields into the stored root, drop the removed ones.
await store.patchAll(delta.changedFields, delta.removedFields);
persisted = delta.version; // not reached if a write threw: retried next time
cache.compact(upTo: persisted); // the only reader: forget what is stored
}
Timer? debounce;
var saving = Future<void>.value();
cache.onChange.listen((_) {
debounce?.cancel();
debounce = Timer(const Duration(milliseconds: 500), () {
// Chained, never concurrent: each save starts from the previous one's version.
saving = saving.then((_) => save()).catchError((Object e) => log(e));
});
});

delta.full is set when the delta replaces everything: after cache.clear(), or when persisted is older than what the cache remembers: compacted away, or — in a cache never compacted — removals forgotten once there are more than a thousand and more than live entities (the delta then carries every entity). A store kept as one JSON map can use delta.applyTo(json).

The copies are real copies: entities are merged in place by later writes (inline objects, lists), so neither snapshot nor a delta can share maps with the store. snapshot copies ~190 entities in about 50 µs in the example’s benchmark, a one-entity delta in about 1.5 µs. fetchedAt stamps are not persisted: hydrated data counts as stale for maxAge (see Fetch policies & freshness and Persistence).

  • Eviction of stale entries. maxAge revalidates stale data (see Fetch policies & freshness) but never drops it; Dart has WeakReference and Finalizer, so stale entries could be held weakly and evicted by the GC rather than by timers.
  • read functions (Apollo’s computed fields / redirects) and possibleTypes inheritance for type policies: a policy keys on one accessor class.