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:
through the exit transition schedules frames for no reason.
of the most expensive things you can composite; clipped to a small card it is
cheap.
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.