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_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:

  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.

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.

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.

❌ successRatePct and ceo are only read once totalLaunches has arrived, so they are only fetched then, in a second request:

example/lib/demos/batching_demo.dart
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:

example/lib/demos/batching_demo.dart
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 null for 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.
example/lib/demos/batching_demo.dart
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 ?? '…'}'),
);
}
}
// ❌ 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 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.

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:

A launch screen whose payloads sit behind “Show payloads”: the builder reads them only once the section is expanded. prepare reads them up front:

example/lib/demos/prepare_demo.dart
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.

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');

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, LaunchesScreen
final 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.