Skip to main content

Request waterfalls

A waterfall is a request that could have started earlier but waited for another one to finish. Each step adds a full round trip, and on a phone network a round trip is what the user waits for. Three requests of 300 ms each take 300 ms side by side and 900 ms in a row.

The library does not create waterfalls, and it cannot remove them either: they come from where the widgets that read the queries sit in the tree. This page is about spotting them and flattening them.

Where they come from​

  • Nested widgets. A parent reads its query and shows a spinner; only when its data arrives does it build the child, and only then does the child's query start. Neither request needed the other, but the tree made them wait.
  • Dependent queries. The second query really does need the first one's answer — the readings of a device whose id comes from a scan. That one is in the data, not in the tree; see dependent queries.
  • Navigation. The detail screen's query starts when its route is built, which is after the tap, after the transition has begun.
  • Code loaded on demand. A deferred library on the web loads first, then the screen in it builds, then its query starts.

Nested widgets​

A device screen shows the device, and under it a chart of its energy use. The chart is its own widget with its own query — which is good design — and it is only built once the device has arrived:

class DeviceScreen extends StatelessWidget {
const DeviceScreen({super.key, required this.id});

final String id;


Widget build(BuildContext context) {
final device = context.query(deviceQuery(id));
return switch (device) {
QueryPending() => const Center(child: CircularProgressIndicator()),
QueryError(:final error) => Center(child: Text('$error')),
QuerySuccess(:final data) => Column(
children: <Widget>[
Text(data.name),
// Built only once the device has arrived — and only then does
// the chart's own query start.
EnergyChart(deviceId: id),
],
),
};
}
}

The requests go out one after the other:

device |-------->
energy |-------->

The energy query did not need the device. It only waited because the widget that reads it is built inside the success branch. There are two fixes.

Read both where the parent reads​

Queries read in the same build start together. Read the chart's query in the screen, and hand the chart its result:

class HoistedDeviceScreen extends StatelessWidget {
const HoistedDeviceScreen({super.key, required this.id});

final String id;


Widget build(BuildContext context) {
// Both read in the same build: both requests start now, side by side.
final device = context.query(deviceQuery(id));
final energy = context.query(energyQuery(id));
return switch (device) {
QueryPending() => const Center(child: CircularProgressIndicator()),
QueryError(:final error) => Center(child: Text('$error')),
QuerySuccess(:final data) => Column(
children: <Widget>[
Text(data.name),
EnergyChartView(energy),
],
),
};
}
}
device |-------->
energy |-------->

The chart has become a plain widget that takes a QueryResult, which also makes it easier to test. The cost is that the screen now knows what the chart reads.

Prefetch in the parent​

When the child should keep its own query, the parent can start the same request early without reading it, and the child joins it when it is built:

class _PrefetchingDeviceScreenState extends State<PrefetchingDeviceScreen> {

void initState() {
super.initState();
// The chart below will read this. Start it now, beside the device's own
// request; the chart joins it, or finds the answer cached.
QueryClientProvider.read(context).query(energyQuery(widget.id)).ignore();
}


Widget build(BuildContext context) {
final device = context.query(deviceQuery(widget.id));
return switch (device) {
QueryPending() => const Center(child: CircularProgressIndicator()),
QueryError(:final error) => Center(child: Text('$error')),
QuerySuccess(:final data) => Column(
children: <Widget>[
Text(data.name),
EnergyChart(deviceId: widget.id),
],
),
};
}
}

client.query(...).ignore() starts the fetch and forgets about it. When the chart is built, its context.query finds the fetch in flight and joins it, or finds the answer cached. Both widgets name the query through the same options function, energyQuery(id), which is what keeps the two in step: the same key, the same function, the same staleTime.

Dependent queries​

When the second request needs the first one's answer, some waiting is unavoidable — but it is often less than it looks. Ask whether the server can answer the second question from what you already have: a device's readings by the device id you navigated with, rather than by a sensor id that only the device's detail contains. If it can, the dependency disappears and both requests run side by side. If it cannot, dependent queries shows how to chain them with enabled.

The detail screen's queries cannot start before the screen is built — unless someone else starts them. The row the user tapped knows exactly what the next screen will read, so it can prefetch on tap, before the push, or earlier still, on hover on the web and desktop. A route guard or a router's redirect can do the same for deep links. Prefetching has the samples.

Code loaded on demand​

A deferred as import on the web downloads the library the first time loadLibrary() is called, and the screen in it builds only afterwards. Start the screen's query next to loadLibrary() rather than inside the screen: the query's options live in your data layer, which is not deferred, so the two downloads run side by side.

Seeing it​

The prefetching screen shows the difference a head start makes. Open a post from the list without prefetching it: the detail waits for its own request. Press a row's prefetch button first (tooltip Prefetch post N), wait for its prefetched pill, then open it: the title is there at once and no request is made.

Live demoPrefetchingWarm the cache before the screen that needs it opens.~3 MB, runs in your browser; no server involved.

Summary​

WaterfallFix
a child's query starts when the parent's data arrivesread both in the parent, or prefetch in the parent
the second query needs the first's answerask the server differently, or chain with enabled
the screen's query starts after navigationprefetch on tap, hover or in the route guard
a deferred screen loads, then fetchesstart the query beside loadLibrary()

Two queries the server could answer in one response are the last kind: if they always go together, one endpoint and one query beat two.

In React Query

The same guide exists there, with Suspense and lazy components as the usual culprits. Flutter has neither, so the cases here are nested widgets, navigation and deferred imports, and the fixes are the same: hoist the read, or prefetch with queryClient.prefetchQuery — client.query(...).ignore() here.