devShakib

Fast Is a Feeling, and I Optimized for the Feeling

Perceived performance in Flutter: use optimistic UI, skeleton screens, instant tap feedback, and prefetching to make apps feel fast without faster code.

A couple of years ago I shipped a Flutter build that was, by every number on my dashboard, slower than the version it replaced. Cold start was up 80ms. The main list screen made one extra network round trip. My profiler traces looked worse across the board. And the first message I got, unprompted, from our support lead in the team channel was: "the new build feels way snappier." I read it twice and felt mildly insulted by my own benchmarks.

That was the moment it clicked. Users don't carry a stopwatch. They carry a nervous system. They measure time in anxiety, not in milliseconds, and the entire job of perceived performance is to keep that anxiety low even when the machine underneath is exactly as slow as it was yesterday. Fast, it turns out, is a feeling. And a feeling is something you can design. This post is the field guide I wish I'd had when I started caring about how apps feel: how human latency perception actually works, and the concrete Flutter patterns — instant tap feedback, optimistic UI, skeleton screens, progressive rendering, and intent prefetching — that make an app feel fast without necessarily making it be fast.

The build that benchmarked worse and felt faster

Here is what actually changed, and why the numbers lied.

The old version was honest to a fault. Tap a button, it disabled the button, showed a centered spinner, waited for the server, then rebuilt the screen. Every path was a clean request-response cycle. Textbook. It also felt like wading through wet sand, because for the entire duration of every action the user stared at a frozen, greyed-out screen wondering if they'd broken something.

The new version did more work. It rendered a skeleton immediately, optimistically applied the user's change before the server confirmed it, and prefetched the next screen the moment intent was obvious. More round trips. More code. More CPU. Worse on the profiler.

But from the user's seat, every tap produced an instant visible reaction, and the screen was never blank and never frozen. Wall-clock time to "done" was slightly longer. Time to "the app is clearly working on it" dropped to near zero. Those are two different clocks, and users only own one of them. I had spent a year optimizing the clock nobody could feel.

The lesson underneath: actual latency and perceived latency are separate quantities you can move independently. You can shrink one without touching the other. Most performance work attacks actual latency because it's what the profiler shows you. Perceived-performance work attacks the gap between when the user acts and when the interface acknowledges they acted — and that gap is usually the cheaper thing to fix and the more valuable one to close.

How humans actually perceive waiting

Interface latency lands in a few rough buckets, and the thresholds have been stable in usability and human-factors research for decades. You don't need the papers. Internalize three numbers.

The trap is treating these as engineering targets ("get it under a second") instead of design boundaries. The subtler truth: the same 800ms feels completely different depending on what's on screen during those 800ms. A blank frozen screen for 800ms feels broken. A screen that reacted instantly and is now filling in feels fast. Identical latency, opposite feeling.

Two more things humans do reliably:

Everything below is tactics for exploiting those two facts. There's a rough hierarchy to them, cheapest-first: instant feedback costs almost nothing and pays the most, so it comes first; prefetching costs the most engineering and carries the most risk, so it comes last.

Nothing you touch is allowed to feel dead

This is the cheapest, highest-leverage rule, so it goes first: the moment a finger lands, something on screen must change — a ripple, a press state, a color shift, a tiny scale. Even if the real work hasn't started. Even if it can't start for 300ms.

Flutter gives you this almost for free, and the number of apps still shipping raw GestureDetector with zero visual feedback is depressing.

// Dead: the user taps and stares at nothing for 250ms.GestureDetector(  onTap: _submit,  child: Container(color: Colors.blue, child: const Text('Save')),);// Alive: instant ripple + press state, real work starts underneath.InkWell(  onTap: _submit,  splashColor: Colors.white24,  child: Ink(    decoration: BoxDecoration(      color: Colors.blue,      borderRadius: BorderRadius.circular(8),    ),    child: const Padding(      padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),      child: Text('Save'),    ),  ),);

The ripple isn't decoration. It's a receipt. It tells the nervous system "your tap registered" in under a frame, which buys you the entire rest of the operation before the user starts to worry. On a recent project my team changed nothing about our network stack for a full sprint, and complaints about "laggy taps" dropped to near zero — purely from auditing every tappable widget for an immediate visual response. That was the cheapest quality win I shipped all year, and it was mostly find-and-replace.

A few micro-interactions in the same family that punch above their weight:

The mental model: never let a frame go by where the user's input hasn't been acknowledged. Sixteen milliseconds of dead air is fine; two hundred is a bug report.

Optimistic UI: showing success before the server agrees

The next lever is bigger and riskier. Most of the time you already know what the answer will be. A like will succeed. A checkbox toggle will save. A message will send. So stop asking permission — apply the change instantly in local state, fire the request in the background, and only reconcile if reality disagrees. This is optimistic UI, and done well it's the single biggest jump in perceived responsiveness you can ship.

The pattern I reach for:

Future<void> toggleFavorite(Item item) async {  final previous = item.isFavorite;  // 1. Update UI immediately. The user sees success now.  setState(() => item.isFavorite = !previous);  try {    await api.setFavorite(item.id, item.isFavorite);  } catch (e) {    // 2. Server disagreed. Roll back and tell the truth.    setState(() => item.isFavorite = previous);    _showRetrySnack('Could not update. Tap to retry.', () => toggleFavorite(item));  }}

Three rules keep optimistic UI honest instead of dishonest:

That last rule deserves a worked example, because it's where most optimistic UI quietly rots. The failure mode is holding the optimistic value in widget state and letting a stream from the server push a competing value into the same tree. Now you have two writers and no referee.

The fix is to funnel both the optimistic write and the server truth through one store — a ChangeNotifier, a Riverpod notifier, a bloc, whatever you use — and never setState directly on the widget for data that also comes from the server:

class FavoriteStore extends ChangeNotifier {  final _state = <String, bool>{}; // itemId -> isFavorite (source of truth)  bool isFavorite(String id) => _state[id] ?? false;  Future<void> toggle(String id) async {    final previous = _state[id] ?? false;    _state[id] = !previous;         // optimistic    notifyListeners();    try {      await api.setFavorite(id, _state[id]!);    } catch (_) {      _state[id] = previous;        // reconcile against the same map      notifyListeners();      rethrow;                      // let the UI surface a retry    }  }  // Server pushes land in the SAME map, so there's never a second writer.  void applyServerSnapshot(Map<String, bool> fresh) {    _state.addAll(fresh);    notifyListeners();  }}

One map, one notifyListeners, one place where optimistic and authoritative values meet. Everything the widget reads comes from isFavorite(id). The flicker bug is now structurally impossible instead of accidentally avoided.

Firestore's offline persistence is a beautiful default here — writes hit the local cache instantly and sync when they can, so a large chunk of optimistic behavior comes for free if you let the SDK do its job instead of await-ing every write behind a spinner. When you call set or update, Firestore updates the local cache and fires your snapshot listener with metadata.hasPendingWrites == true immediately, before the network confirms anything. If you're rendering from the stream, your UI already reflects the change; the network round trip happens invisibly underneath. Half the "optimistic UI" I've written was really just me getting out of Firestore's way and not blocking the UI on a write that the SDK was happy to buffer.

Skeletons and progressive rendering over the honest spinner

A spinner communicates exactly one bit: "something is happening, no idea what or how much longer." It's the interface equivalent of a shrug. Worse, a centered spinner erases the layout, so when content arrives the whole screen jumps — which reads as a second, jarring load and often as a layout shift that scrolls the thing you were about to tap.

Skeleton screens fix both problems. They show the shape of what's coming, so the layout stays stable and the brain starts parsing structure before any real data lands. The screen looks populated and calm instead of empty and pending.

Widget buildTile(AsyncSnapshot<User> snap) {  if (!snap.hasData) {    // Skeleton: same layout, shimmering placeholders. No spinner, no jump.    return const _SkeletonTile();  }  return UserTile(user: snap.data!); // Real content slots into the same shape.}

The detail that makes skeletons work is that the placeholder must occupy the exact same dimensions as the real content. A skeleton tile that's 72px tall feeding into a real tile that's 80px tall still produces a jump — you've just made the jump prettier. Build the skeleton from the same layout constants as the real widget, and add a subtle shimmer (a moving gradient) so it reads as "loading" rather than "broken empty state." A static grey block looks like a bug; a shimmering one looks like anticipation.

The rule I follow for choosing the indicator:

Progressive rendering is the same idea across time: paint whatever you have the instant you have it. Show the cached avatar and name immediately; let the fresh stats stream in a beat later. Never hold the whole screen hostage waiting for the slowest field. A screen that fills in from top to bottom feels alive. A screen that appears all at once after a blank pause feels slow, even when it's technically faster to full render.

In practice this means splitting one loading state into several. Instead of a single isLoading boolean gating the whole page, load the cheap, cached, above-the-fold data on its own path and render it first, then let the expensive below-the-fold data resolve independently:

// One boolean = whole screen waits for the slowest thing.// Several async slots = each part appears the moment it's ready.Column(  children: [    ProfileHeader(user: cachedUser),        // instant, from cache    _statsSlot(statsFuture),                // resolves a beat later    _activitySlot(activityFuture),          // resolves whenever the API does  ],);

The header is on screen in the first frame; the slower slots swap their own skeletons for real data as they arrive. The user is reading and oriented while the network is still working.

Prefetching intent: loading what the user is about to want

The fastest network request is the one that finished before the user asked. Most navigation is predictable — someone scrolling a product list is very likely about to open one of those products. So start loading the likely-next screen during the idle moment before the tap. This is intent prefetching, and it's the most advanced lever here because it trades bandwidth and complexity for latency you erase entirely.

Cheap places to prefetch:

// Warm the detail cache as tiles become visible.VisibilityDetector(  key: ValueKey(item.id),  onVisibilityChanged: (info) {    if (info.visibleFraction > 0.5) {      _repo.prefetchDetail(item.id); // fire-and-forget into cache    }  },  child: ProductTile(item: item),);

Two cautions, and they're what separate prefetching that helps from prefetching that just drains batteries. Don't prefetch so aggressively that you burn the user's mobile data or your own read quota — I keep prefetch to obvious, high-probability intent, cap concurrency (a small worker pool, not a fetch-per-visible-tile stampede), and I'll happily gate heavy image prefetch behind a Wi-Fi check. And always prefetch into the same cache the real screen reads from, so a finished prefetch turns the eventual open into an instant cache hit rather than a duplicate request the user waits on anyway. A prefetch the real screen ignores is just a battery tax with extra steps.

The reconciliation detail matters here too: if the prefetch is still in flight when the user taps, the detail screen should await the same in-flight future rather than starting a second identical request. De-duplicating in-flight requests by key is the difference between prefetching that saves a round trip and prefetching that doubles them.

When perceived performance becomes a lie the user resents

Every technique here is, in a sense, a small deception — you're managing someone's perception of time. That's fine right up until perception and reality diverge in a way that costs them something. Then it curdles into distrust, and distrust is far more expensive than a slow screen.

The lines I don't cross:

The test I apply: if the user could see everything my code is doing behind the illusion, would they feel served or fooled? Instant tap feedback, skeletons, and prefetching pass — they're honest presentation of work that's genuinely underway. A fake 90% progress bar fails, every time. The good techniques dress up real work; the bad ones fabricate work that isn't happening. Stay on the right side of that line and perceived performance is just good manners. Cross it and it's manipulation your users will eventually catch.

Measuring the feeling, not just the latency

You can't improve what you only measure with averages. The mistake is optimizing mean latency; the feeling lives in the tails and in the first reaction, not the mean.

What I actually track:

We wired the first two into analytics on a recent build and found a screen that benchmarked fine on average but had a brutal p99 — a small subset of users on slow connections were eating five-second blank waits. Fixing the feeling for that tail (a skeleton plus progressive render) moved our satisfaction numbers more than any raw speedup we shipped that quarter. The raw speedups were harder, took longer, and nobody noticed them. That still bothers me a little.

Key takeaways