Where redundant API calls come from in a production Flutter app
On an app that already has users, cutting unnecessary network traffic by around forty per cent did not take a rewrite. Nearly all of it came from three patterns: fetches triggered by rebuilds, screens that reload their entire state on every navigation, and identical requests firing concurrently because nothing deduplicated them.
First, measure — the guesses are usually wrong
Before changing anything, log every outbound request with its endpoint, a timestamp and the widget or bloc that triggered it. Ten minutes of ordinary use produces a list that is almost always surprising, and it stops you optimising an endpoint that fires twice a session while ignoring one firing on every frame.
An interceptor on your HTTP client is enough. You are looking for two things: the same URL appearing repeatedly in a short window, and requests firing during interactions that should not need the network at all — scrolling, opening a sheet, rotating the device.
Keep that log. It is also how you prove the change worked, which matters when the improvement is invisible in the UI.
Source one: fetching inside build
Anything that starts a request from `build()` — directly, or through a provider constructed inline — will refetch every time the widget rebuilds, and widgets rebuild far more often than most people assume. This is the largest single source of redundant traffic I have found in production apps.
The tell is a request that fires when nothing about the data changed: a keyboard opening, a parent rebuilding, a theme change, an animation frame. Because each individual call is cheap, this hides until you look at the log.
The fix is to move the trigger out of the render path — into `initState`, a bloc event, or a provider that caches its own result — so the fetch is tied to an intent rather than to a repaint.
Source two: navigation that discards state
If pushing a detail screen and popping back refetches the list, the list state is being rebuilt rather than retained. On a slow connection this is what users describe as the app feeling heavy — a spinner where they expected the screen they just left.
Hold list state above the navigation boundary, so returning to a screen restores what was already loaded. In BLoC terms, the bloc outlives the route rather than being created by it.
Then make refresh explicit: pull-to-refresh, or an event when something is known to have changed. Deliberate refresh is both cheaper and more predictable than refetch-on-every-appearance, and users understand it better.
Source three: concurrent duplicate requests
Two widgets asking for the same resource at the same moment produce two identical in-flight requests. Keep a map of in-flight futures keyed by request, return the existing future when one is already running, and the second caller waits on the first instead of duplicating it.
final _inFlight = <String, Future<Response>>{};
Future<Response> get(String key, Future<Response> Function() send) {
final existing = _inFlight[key];
if (existing != null) return existing;
final future = send().whenComplete(() => _inFlight.remove(key));
_inFlight[key] = future;
return future;
}This is a small amount of code that removes an entire category of waste, and it also removes a class of race condition where two responses for the same resource arrive out of order and the older one wins.
Cache with an explicit lifetime, not forever
Add a short time-to-live to responses that do not change second by second, and serve from cache within that window. Even thirty seconds eliminates most repeat traffic during a single session, and unlike an unbounded cache it cannot leave users staring at stale data.
Decide the lifetime per endpoint rather than globally: a currency rate and a user profile do not have the same tolerance for staleness.
Pair it with cache invalidation on the events that genuinely change the data — a successful write should clear what it affects, rather than waiting for a timer.
Debounce anything driven by typing
Search fields wired straight to a request send one per keystroke. Debouncing by a few hundred milliseconds turns a nine-request word into one, and cancelling the previous request when a new one supersedes it stops out-of-order responses overwriting the current results.
Cancellation matters as much as debouncing here. Without it, a slow response to "rec" can land after the fast response to "receipt" and repopulate the list with the wrong results — a bug that is maddening to reproduce and obvious once you see the log.
What this adds up to
None of these are clever. They are ordinary discipline about when a request is allowed to happen, applied consistently. The effect on a real app is a large reduction in traffic, a proportional reduction in backend cost, and an app that feels faster without a single change to how it looks.
The reason to do the measurement first is that it tells you which of the three sources dominates in your app, and they are rarely equal. On the apps I have worked on, fetching from `build()` has been the biggest by some distance — and the cheapest to fix once you can see it.
Related
I build AI features into production mobile apps, and stabilise the apps underneath them. See what working together looks like.

