Skip to content

Batching & waterfalls

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:

launches_screen.dart (simplified)
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
);
},
)),
])
the only request
query ($first: Int) {
company { __typename name }
stats { __typename totalLaunches }
launches_1qouruf: 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:

  1. The very first build happens outside a frame. runApp mounts and builds the tree in attachRootWidget, then schedules a warm-up frame as a separate event-loop task. A microtask queued during that build runs before the frame.
  2. Slivers build children during layout, not during the build phase. SliverList rows that read launch.name do so after their parent’s build() 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.

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.

// ❌ ceo and successRatePct are only read once totalLaunches is non-null
final text = stats?.totalLaunches == null
? null
: '${stats!.totalLaunches} launches · ${stats.successRatePct}% · CEO ${company?.ceo}';
// ✅ read everything first, format second
final total = stats?.totalLaunches, rate = stats?.successRatePct, ceo = company?.ceo;
final text = total == null ? null : '$total launches · $rate% · CEO $ceo';
// ❌ endCursor is first read on tap → fetch → the tap adds `null` as a cursor
final _cursors = <String?>[null];
CupertinoButton(
onPressed: () =>
setState(() => _cursors.add(page!.pageInfo!.endCursor)),
)
// ✅ let PaginatedQueryBuilder own the cursor list; 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.

Read every field the widget might need at the top of build(), into locals. Branch on the locals, not on the accessors.

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:

launch_screen.dart (excerpt)
QueryBuilder<Query>(
prepare: (query) {
final launch = query.launch(id: widget.id);
if (launch != null) LaunchScreen.prepare(launch); // reads payloads, crew, rocket…
},
builder: (context, query, state) {
final launch = query.launch(id: widget.id);
// …the payload list is rendered only when _showPayloads is true,
// but it was fetched up front → expanding costs 0 requests.
},
)

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.

The most valuable test in the repository is the one that counts requests:

example/test/app_test.dart (excerpt)
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 launch
expect(log.entries, hasLength(3), reason: 'detail screen: exactly one request');
// expand payloads
expect(log.entries, hasLength(3), reason: 'no request when expanding: prepare paid for it');

Hook SlingClient.onOperation to a list, and assert on its length after each interaction. Every waterfall shows up as an off-by-one.