FutureBuilder Done Right: Caching Futures, Killing Flicker, and Testing
The FutureBuilder contract from the API docs — futures created in initState, the one-frame waiting flash, initialData, stale-result guards, and deterministic widget tests.
Published on • November 13, 2026
AI Assistant

flutter-streambuilder-futurebuilder-async-ui teaches the AsyncSnapshot model shared by both builders. This post goes one level deeper into FutureBuilder specifically, because the class documentation contains a set of precise contracts that most code violates — and every violation shows up as flicker, restarted requests, or tests that lie. Everything below is drawn from the FutureBuilder API reference.
Contract 1: the future must already exist
The docs are unambiguous: the future “must have been obtained earlier, e.g. during State.initState, State.didUpdateWidget, or State.didChangeDependencies,” and must not be created during build. If the future is created at the same time as the widget, every parent rebuild restarts the async task.
class ProfileView extends StatefulWidget {
const ProfileView({super.key, required this.userId});
final String userId;
@override
State<ProfileView> createState() => _ProfileViewState();
}
class _ProfileViewState extends State<ProfileView> {
late Future<User> _user;
@override
void initState() {
super.initState();
_user = Api.fetchUser(widget.userId); // obtained once
}
@override
void didUpdateWidget(ProfileView old) {
super.didUpdateWidget(old);
if (old.userId != widget.userId) {
_user = Api.fetchUser(widget.userId); // re-fetch only on change
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: _user,
builder: (context, snap) => switch (snap) {
AsyncSnapshot(hasData: true) => ProfileBody(snap.data!),
AsyncSnapshot(hasError: true) => ErrorView(snap.error!),
_ => const LoadingShimmer(),
},
);
}
}
The API docs state the guideline plainly: assume every build could be called every frame.
Contract 2: an already-completed future still flashes waiting
Side effect of the pipeline design: “providing a new but already-completed future to a FutureBuilder will result in a single frame in ConnectionState.waiting,” because there is no synchronous way to ask a Future whether it has completed. Cached instant data therefore still produces one frame of spinner unless you suppress it with initialData:
FutureBuilder<User>(
future: _user,
initialData: _cache, // show last-known user for that one frame
builder: ...,
)
The snapshot contract for a successful future (with no initialData) is exactly two possible states — waiting with null data, then done with data — and on an error, waiting then done with the error attached. Knowing the exact sequence is what makes the widget testable.
Contract 3: swapping futures keeps the old snapshot’s data
When configuration changes to a new future, the docs describe the snapshot pair you’ll see: ConnectionState.none carrying the first future’s data, then waiting for the second. Data and error fields are retained across the swap. This is deliberate — it lets you keep showing stale-but-useful content while the next request loads, which is exactly the behavior a pull-to-refresh flow wants: old list stays visible, spinner shows, new data replaces it.
The bugs this contract prevents
- Restarted requests on rebuild → create the future in
initState/didUpdateWidget. - Spinner flicker on cached data →
initialDatafrom your cache layer. - Stale results overwriting fresh ones → when you re-trigger a fetch yourself (retry button), keep a monotonically increasing request token and ignore completions whose token is no longer current;
FutureBuilderhas no built-in cancellation. - Lost errors →
AsyncSnapshot.hasErroris the only error channel; rethrow in debug withdebugRethrowErrorif you want the exception surfaced in tests.
Testing the exact snapshot sequence
Because the builder contract is documented, tests can assert it deterministically:
testWidgets('FutureBuilder shows data after the future completes', (tester) async {
final completer = Completer<String>();
await tester.pumpWidget(FutureBuilder<String>(
future: completer.future,
builder: (context, snap) => Text('${snap.connectionState}:${snap.data ?? '-'}'),
));
expect(find.text('ConnectionState.waiting:-'), findsOneWidget);
completer.complete('hello');
await tester.pumpAndSettle();
expect(find.text('ConnectionState.done:hello'), findsOneWidget);
});
No network, no real delays — a Completer gives you both frames on demand, and the documented two-snapshot contract is exactly what you assert against.
Takeaway: Obtain the future in initState or didUpdateWidget, never in build; pass initialData to erase the one-frame waiting flash on cached data; guard re-fetches with your own token because the builder can’t cancel; and lean on the documented snapshot sequence to write flicker-proof, completer-driven widget tests.