Loading devShakib…
A spinner that appears for three frames reads as a glitch. The fix is a reveal delay and a minimum hold — plus the six things a bool in a Stack never handles.
Every Flutter app has this somewhere:
bool _loading = false;
Future<void> _signIn() async {
setState(() => _loading = true);
try {
await api.signIn(email, password);
} finally {
setState(() => _loading = false);
}
}
And a Stack with a spinner on top when _loading is true. It is the first thing everyone writes, it is in every tutorial, and it is correct in the sense that the state is never wrong.
It also looks broken on a fast connection, and the reason is worth understanding properly — because the fix is not a nicer spinner, and no amount of animation polish will help.
Your API call returns in 90 milliseconds. At 60fps that is about five and a half frames.
So the spinner mounts, paints for five frames, and unmounts. The user does not perceive "loading". They perceive the screen flinching — a flash of grey, a shape that appeared and left before their eye could settle on it. On a fast network, every single tap does this.
It gets worse when the timing sits near the boundary. A call that takes 200ms on Wi-Fi and 900ms on cellular produces two entirely different experiences from the same code: a flash on one, a legible wait on the other. Users on the fast network get the glitchy one, which is precisely backwards.
There is decades-old research on this. Jakob Nielsen's response-time limits put 0.1 seconds as the threshold below which an action feels instantaneous, and 1 second as the limit for uninterrupted flow of thought. The important implication is the one most implementations miss: below about 100ms, showing progress is worse than showing nothing, because the feedback itself becomes the disruption.
A bool cannot express that. A bool says "in flight" or "not in flight", and maps both directly to pixels. What you need is a third state — in flight, but not yet worth mentioning — and that requires a clock.
Start a timer when the operation begins. If the operation finishes before the timer fires, render nothing at all. No spinner, no scrim, no success tick. The screen never moves.
A threshold somewhere around 140ms works well in practice. Below it, an operation is fast enough that feedback is noise. Above it, the user has begun to wonder whether their tap registered, and feedback is reassurance.
This single rule eliminates the majority of flicker in a typical app, because the majority of requests in a typical app are fast. Cached reads, local database queries, a warm API on a good connection — all silent.
The second rule is less obvious and just as important.
Say your reveal delay is 140ms and the request finishes at 170ms. Without a second rule, the spinner appears for 30 milliseconds. You have replaced a five-frame flash with a two-frame flash, which is worse.
So once the overlay commits to appearing, it stays for a minimum — half a second is a good default. The user waits slightly longer than strictly necessary, and in exchange the interface reads as deliberate rather than glitchy.
That trade is worth being explicit about, because it sounds wrong at first: you are deliberately making the app slower. But perceived performance is not average latency. An interface that is 300ms slower and never flinches feels faster and more trustworthy than one that is technically quicker and visibly twitchy. The user is not timing you with a stopwatch; they are forming an impression of whether the software is solid.
Those two rules are the whole reason loading_kit exists:
final user = await Loading.run(
() => api.signIn(email, password),
message: 'Signing in…',
successMessage: 'Welcome back',
);
run rethrows whatever the task threw, so your error handling is unchanged. Everything else in the package follows from taking blocking state seriously rather than from having more spinner shapes.
Timing is the headline. These are the problems that show up afterwards, once a real app grows past one screen — and each one is a bug I have seen in production code that started as bool _loading.
Two operations start. The first finishes and sets _loading = false. The second is still running, and the overlay is gone — the user can now tap into a screen that is mid-mutation.
The fix is reference counting: the overlay leaves when the last operation retires, not the first. Related: if one request succeeds while another is still in flight, busy must outrank settled, or a check mark flashes mid-flight and tells the user the work is done when it is not.
A request starts on the profile screen. The user hits back. The request fails slowly. The finally block runs on a State that is no longer mounted, or the overlay is hosted above the navigator and is now sitting over a completely different screen with a message about a profile the user has left.
This needs route awareness — an observer that clears overlays when the route beneath them changes. A bool in a State cannot know that a navigation happened somewhere above it.
A Stack with a spinner on top does not necessarily block taps. If your overlay does not have an opaque hit-test target, the buttons underneath are still live and the user can fire the same request three times.
Even with taps blocked, a hardware keyboard can still tab to buttons under the scrim and activate them with Enter. On desktop and web that is a real double-submit path, and it is invisible in testing because nobody tabs during a loading state on purpose.
The blocked subtree needs to come out of focus traversal entirely.
A visual spinner communicates nothing to a screen reader user. Without markup they get silence, then the screen changes.
The overlay should be a live region that announces its message and its progress, and — just as important — the blocked application underneath should be hidden from the accessibility tree with BlockSemantics, or the user can navigate to controls that no longer work.
If the platform asks for reduced motion, the entrance animation should drop its scale. It is a small thing that takes one conditional, and it is the sort of thing that never gets added to a hand-rolled overlay because it is nobody's ticket.
The mistake in the other direction is blacking out the entire application for something small.
A form that saves in place does not need the whole screen scrimmed — it needs that form to stop accepting input. Scoping the overlay to a subtree keeps the rest of the app usable, and it should still apply the same timing policy so a fast save flashes nothing:
LoadingBarrier(
loading: _saving,
message: 'Saving…',
borderRadius: BorderRadius.circular(16),
child: const ProfileForm(),
)
And a great deal of what gets a spinner should not block anything at all. Work that has already succeeded, or that continues in the background, wants a toast:
Loading.toast('Draft saved');
Loading.toastSuccess('Order placed');
Loading.toastError('Could not sync', detail: 'Retrying in the background');
The rule of thumb: block only when proceeding would be wrong. If the user could carry on doing something else while this finishes, taking the screen away from them is a bug in the design, not a loading state.
Indeterminate spinners are honest when you genuinely do not know how long something will take. When you do know — uploading eleven files, processing a list — show it:
await Loading.runTask((task) async {
for (var i = 0; i < files.length; i++) {
task.throwIfCancelled();
task.report((i + 1) / files.length, detail: '${i + 1} of ${files.length}');
await upload(files[i]);
}
}, message: 'Uploading…', cancelAfter: const Duration(seconds: 3));
Two details in there matter more than the progress number.
cancelAfter reveals the cancel affordance only once that much time has passed. A cancel button on a one-second operation is clutter that nobody will ever click. A cancel button on a thirty-second upload is the difference between a patient user and a force-quit.
Cancellation is cooperative. Tapping cancel rejects the future immediately so the UI responds at once, and the body stops at its next throwIfCancelled(). Pretending you can abort arbitrary Dart mid-execution would be a lie; making the cancellation points explicit is the honest version.
For long work, a bar reads better than a ring. The difference between 60% and 70% is obvious in a line and genuinely hard to judge in a circle:
LoadingStyle.material.copyWith(progressStyle: LoadingProgressStyle.bar)
A detail that is easy to skip and changes how the whole thing feels.
Most implementations swap widgets at each stage: a CircularProgressIndicator, then an Icon(Icons.check), then nothing. Three unrelated shapes appearing in the same slot. Each swap is a small visual discontinuity.
Drawing all the states as one CustomPainter means the arc closes into a ring, crosses to the terminal colour, and strokes the check inside itself. The outcome grows out of the waiting rather than replacing it. It costs one painter instead of three widgets, and it is the difference between an interface that transitions and one that cuts.
An overlay host sits above your entire app, so its cost while nothing is loading matters more than its cost while something is.
With nothing in flight it should build SizedBox.shrink() — no scrim, no blur, no ticker, no hit-test target. And critically, the app subtree must be passed through by identity, so that when loading starts and stops Flutter can skip rebuilding your entire application. If your overlay wrapper rebuilds its child, every loading state in your app is now a full rebuild.
Two more that add up:
The nice property of making the rules explicit is that they become testable — on Flutter's fake clock, with no real waiting:
testWidgets('a fast call shows nothing', (tester) async {
final controller = LoadingController();
addTearDown(controller.dispose);
final work = controller.run(
() => Future.delayed(const Duration(milliseconds: 80)),
);
await tester.pump(const Duration(milliseconds: 80));
expect(controller.value.visible, isFalse);
await tester.pump(const Duration(milliseconds: 400));
await work;
});
One caveat that applies to any indeterminate indicator, including Flutter's own: a spinning arc schedules a frame forever, so pumpAndSettle() will never settle while one is on screen. Pump explicit durations instead. This catches people out and produces mysterious test timeouts that look like a deadlock.
You do not need a package for any of this. The two rules are twenty lines:
That list is most of the value, and it applies whatever you build it with.
If you would rather have it done: loading_kit is on pub.dev — MIT, no dependencies, all six platforms, 160/160 pub points. Five presets that resolve against your ThemeData, six indicator styles, and an indicatorBuilder slot if you would rather supply a Lottie file or your own brand mark and keep only the timing behaviour.