Batching & waterfalls
One frame, one request
Section titled “One frame, one request”Every QueryBuilder owns a scope. During its build, each accessor read that misses
the cache tells the scope; the scope adds the field to a selection tree and asks the client
to flush. The client does not flush immediately — it merges every scope’s tree into one
document and sends it once, at the end of the frame.
That is why this screen, made of two independent QueryBuilders plus twenty lazily built
rows, costs a single request:
Column(children: [ // Widget 1: header QueryBuilder<Query>(builder: (_, q, s) => Text('${q.company?.name} · ${q.stats?.totalLaunches}')), // Widget 2: list Expanded(child: QueryBuilder<Query>( builder: (_, q, s) { final page = q.launches(first: 20); return ListView.builder( itemCount: page?.nodes?.length ?? 1, itemBuilder: (_, i) => _LaunchRow(page!.nodes![i]), // Widget 3…22, built during layout ); }, )),])query ($first: Int) { company { __typename name } stats { __typename totalLaunches } launches_c0y93u18lig6v: launches(first: $first) { __typename nodes { __typename status name date id rocket { __typename name } } pageInfo { __typename hasNextPage endCursor } totalCount }}Why “end of frame” and not “next microtask”
Section titled “Why “end of frame” and not “next microtask””The first version of sling_gql flushed on the next microtask. On a real device the first screen produced two requests. Two Flutter facts explain it:
- The very first build happens outside a frame.
runAppmounts and builds the tree inattachRootWidget, then schedules a warm-up frame as a separate event-loop task. A microtask queued during that build runs before the frame. - Slivers build children during layout, not during the build phase.
SliverListrows that readlaunch.namedo so after their parent’sbuild()returned.
So the microtask fired with the header and the list container selected, then the rows
recorded their fields and triggered a second request. QueryBuilder scopes now flush in a
post-frame callback (frameEndScheduler), after layout, so lazily built children join
their parents’ request. Imperative resolve() keeps the microtask.
QueryBuilder(scheduler:) overrides this per widget. Pass microtaskScheduler only when no
frame is coming (a headless binding, a test that never pumps) or the widget’s own build
records everything it will ever read. Anything with slivers or lazily built children
should keep the default: a microtask splits the screen into one request per task.
Whichever scope schedules a batch first picks its scheduler; scopes that record later join
that batch.
Waterfalls: the one habit to build
Section titled “Waterfalls: the one habit to build”The flip side of “what you read is what you fetch” is: what you don’t read isn’t fetched. A field read only after some fetched data arrived costs a second round trip. The example’s request-counting test caught two of them in code written by someone who knew about the problem.
Anti-pattern 1 — conditional reads
Section titled “Anti-pattern 1 — conditional reads”❌ successRatePct and ceo are only read once totalLaunches has arrived, so they are
only fetched then, in a second request:
class HeaderReadInsideACondition extends StatelessWidget { const HeaderReadInsideACondition({super.key});
@override Widget build(BuildContext context) { return QueryBuilder<Query>( builder: (context, query, state) { final stats = query.stats; // successRatePct and ceo are only read once totalLaunches arrived: // they are only fetched then, in a second request. return Text( stats?.totalLaunches == null ? 'Loading…' : '${stats!.totalLaunches} launches · ${stats.successRatePct} % ' '· CEO ${query.company?.ceo}', ); }, ); }}✅ Read everything first, format second:
class HeaderReadAtTheTop extends StatelessWidget { const HeaderReadAtTheTop({super.key});
@override Widget build(BuildContext context) { return QueryBuilder<Query>( builder: (context, query, state) { // Read every field first... final total = query.stats?.totalLaunches; final rate = query.stats?.successRatePct; final ceo = query.company?.ceo; // ...then branch on the locals. return Text( total == null ? 'Loading…' : '$total launches · $rate % · CEO $ceo', ); }, ); }}Try both. The screen is that header above a list of launches, each row its own widget reading its own fields:
- Read at the top: header, list and rows arrive together, in 1 request.
- Read inside a condition: the header shows
nullfor the rate and the CEO for a moment, until a second request fetches them. Look at what that request asks for: only the two fields the condition hid. - ↻ throws the cache away and runs the screen again.
class LaunchList extends StatelessWidget { const LaunchList({super.key});
@override Widget build(BuildContext context) { return QueryBuilder<Query>( builder: (context, query, state) { final launches = query.launches(first: 6)?.nodes ?? const []; return ListView( children: [for (final launch in launches) LaunchTile(launch)], ); }, ); }}
/// Its own widget, reading its own fields: they join the list's request.class LaunchTile extends StatelessWidget { const LaunchTile(this.launch, {super.key}); final Launch launch;
@override Widget build(BuildContext context) { final name = launch.name; final rocket = launch.rocket?.name; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Text('${name ?? '…'} · ${rocket ?? '…'}'), ); }}Anti-pattern 2 — reads inside callbacks
Section titled “Anti-pattern 2 — reads inside callbacks”// ❌ endCursor is first read on tap → fetch → the tap adds `null` as a cursorfinal _cursors = <String?>[null];CupertinoButton( onPressed: () => setState(() => _cursors.add(page!.pageInfo!.endCursor)),)
// ✅ let PaginatedQueryBuilder own the next cursor; it reads endCursor// during its own build, so a tap never has to read it for the first time.CupertinoButton(onPressed: state.loadMore)See the pagination guide for the full
PaginatedQueryBuilder API — the snippet above is what it does internally.
The rule
Section titled “The rule”Read every field the widget might need at the top of
build(), into locals. Branch on the locals, not on the accessors.
prepare: fetching for the future
Section titled “prepare: fetching for the future”Some reads are legitimately conditional — a collapsed section, a tab that isn’t visible
yet. For those, QueryBuilder.prepare runs a selection function in addition to the
builder, so the fields are part of the first request even though nothing displays them yet:
A launch screen whose payloads sit behind “Show payloads”: the builder reads them only
once the section is expanded. prepare reads them up front:
class LaunchWithPrepare extends StatelessWidget { const LaunchWithPrepare({super.key});
@override Widget build(BuildContext context) => _Screen( // Read now what the collapsed section will show: it is part of the // first request, and expanding costs nothing. prepare: (query) { final payloads = query.launch(id: _launchId)?.payloads; for (final p in payloads ?? const <Payload>[]) { p ..name ..orbit; } }, );}- Without prepare: the screen costs 1 request. Tap Show payloads: a second
request asks for
payloads { name orbit }, and the list shows up a moment later. - With prepare: the same 1 request already has the payloads. Tap Show payloads: they are there at once, and the count stays at 1.
prepare functions are also your fragments: export them and reuse the same selection
in a list row, a detail page, and later in a mutation’s response selection.
Test your request counts
Section titled “Test your request counts”The most valuable test in the repository is the one that counts requests:
expect(log.entries, hasLength(1), reason: 'header + list + rows batched');// tap "Load more"expect(log.entries, hasLength(2), reason: 'only the second page is fetched');// open a launchexpect(log.entries, hasLength(3), reason: 'detail screen: exactly one request');Hook SlingClient.onOperation to a list, and assert on its length after each interaction.
Every waterfall shows up as an off-by-one.
See every request: the overlay and the log line
Section titled “See every request: the overlay and the log line”Each operation the client sends — a query batch, a mutation, a subscription connection —
is a SlingRequest on client.requests: the root fields, how many fields the widgets
read, the duration, the response size, the error, and the scopes whose reads are in the
document. Two ready-made views:
// One line per operation in the console, once it is done:// sling_gql #3 query launches, company · 42 ms · 1.2 KB · 18 fields ← _Header, LaunchesScreenfinal client = SlingClient<Query>(endpoint: endpoint, schema: slingSchema, logRequests: true);
// A chip over the app (debug builds): tap it for the list, tap a line for its document.CupertinoApp(builder: (context, child) => SlingRequestOverlay(child: child!), ...);The overlay goes below the SlingScope (or takes client:) and is only child in release
builds (enabled: overrides it). Records are only built while someone listens to
client.requests or logRequests is on, so both cost nothing when off.
Attribution is by scope: a QueryBuilder is named by its debugLabel, its key, or the
widget around it; a MutationBuilder / SubscriptionBuilder by debugLabel: or the widget
around it; mutateWith / subscribeWith take a debugLabel: too. Twelve rows of the same
widget type fold into LaunchRow ×12. A line with two unrelated screens on it is batching
at work; the same screen on two consecutive lines is the waterfall the warning above
talks about.