Skip to content
Umar ManzoorBook a call
← Writing

Your UI Shouldn't Wait for the Network

July 21, 2026

Most mobile apps don't feel slow because the network is slow. They feel slow because the UI is waiting for the network.

After building apps used across the globe — 5G, unreliable public Wi-Fi, 2G, and no connection at all — the pattern is consistent. The apps users call "fast" aren't necessarily the ones making the quickest API calls. They're the ones that never made the screen depend on a request finishing in the first place.

That reframe is the whole thing. Once you stop treating the network as the source of what's on screen, low-network resilience stops being a feature you bolt on and starts being the natural shape of the app. Three principles get you most of the way there.

1. A single source of truth

Your UI should read from exactly one place: a local store — a database or a cache. The network's only job is to update that store. The UI's only job is to observe it.

Network ──▶ Local store ──▶ UI

Notice what's not in that chain: the UI never reads a network response directly. It reads local data and reacts when local data changes. The network sits behind the store, quietly keeping it current.

In Flutter, that's a repository that exposes a stream backed by the local store:

class OrdersRepository {
  final LocalOrderStore _store; // the single source of truth
  final OrdersApi _api;

  OrdersRepository(this._store, this._api);

  // The UI watches this. It comes from the store — never from the network.
  Stream<List<Order>> watchOrders() => _store.watchAll();

  // Refresh is fire-and-forget: it updates the store, and the stream does the rest.
  Future<void> refreshOrders() async {
    try {
      final fresh = await _api.fetchOrders();
      await _store.upsertAll(fresh); // updating the store updates the UI
    } on NetworkException {
      // Offline: keep serving whatever the store already holds. No error on screen.
    }
  }
}

The screen observes the store and nothing else:

StreamBuilder<List<Order>>(
  stream: repo.watchOrders(),
  builder: (context, snapshot) {
    if (!snapshot.hasData) return const OrdersSkeleton(); // first launch only
    final orders = snapshot.data!;
    if (orders.isEmpty) return const OrdersEmptyState();
    return OrdersList(orders);
  },
);

Once the UI observes local data instead of network responses, offline support becomes an outcome rather than a special case. Users always have the last known state, connectivity or not. Reopen the app on a train in a tunnel and the last data is right there — because the widget was never asking the network for it.

This is the same idea I keep coming back to in state management and event-driven communication: the UI shouldn't know where data comes from. It should read from one owner and react. Here, that owner is a persistent store.

2. Cache-first by default

Don't make users wait for a request before you show them anything. Show what's in the store immediately, refresh in the background, and update when fresh data arrives. This is stale-while-revalidate, and it's the default that makes a bad network feel fine.

The refresh above already does this: the UI is already painted from the store; refreshOrders() runs in the background and, if it succeeds, the store update flows back into the same stream. The user sees content first, freshness second.

But "cache-first" isn't a single rule for the whole app. Different data deserves different strategies:

  • Cache-first for feeds, lists, and mostly-read content. Instant, refresh behind the scenes.
  • Network-first for critical, must-be-current operations — a payment confirmation, a live balance. Here you do wait, because showing stale data would be wrong; but you fall back to cache if the network fails.
  • Cache-only for data that rarely changes — reference data, config, a catalogue you sync occasionally. No request on the hot path at all.

A small policy makes this explicit instead of scattered if statements:

enum CachePolicy { cacheFirst, networkFirst, cacheOnly }

The repository picks behaviour per call. Feeds pass cacheFirst; the checkout screen passes networkFirst. The point isn't the enum — it's deciding, deliberately, which data can be stale for a moment and which cannot.

And the part people skip: persist the cache. An in-memory cache evaporates on restart, which means a cold launch with no signal opens to an error screen — the worst possible first impression on a bad network. Back the store with something durable — Drift, Isar, or Hive for structured local data, or an HTTP cache interceptor for response-level caching — so a cold start offline still opens to real content.

3. Skeletons, not spinners

When there is genuinely nothing to show yet, how you say "loading" changes how fast the app feels.

A skeleton — a greyed-out sketch of the layout that's about to appear — tells the user "your content is loading, and here's roughly what it'll look like." A spinner says "please wait… maybe." One implies progress and shape; the other implies indefinite uncertainty.

if (!snapshot.hasData) return const OrdersSkeleton();

The OrdersSkeleton mirrors the real list: a few placeholder rows with shimmer blocks where the avatar, title, and subtitle will land. When real data arrives, the layout doesn't jump — the skeleton had the same shape.

Here's why this matters more than it looks: combined with cache-first, the skeleton is usually seen exactly once. The very first launch, before the store has anything, shows the skeleton. Every launch after that, the store already holds data, so snapshot.hasData is true immediately and the user goes straight to content while the refresh happens invisibly. Perceived performance is performance — and on a weak connection, perception is most of the battle.

Writes are harder than reads

Everything so far is about reading. Writing offline — letting a user do something without a connection — is where offline-first earns its keep, and where it gets genuinely harder.

The shape that works:

User action (offline)
        │
        ▼
  write to local store        ← optimistic: the UI updates immediately
        │
        ▼
  enqueue a pending mutation
        │
   NetworkConnected           ← an event, not a poll
        │
        ▼
  sync worker drains the queue ─▶ backend ─▶ reconcile the store

The user taps, you write to the local store optimistically so the UI reflects it at once, and you record a pending mutation. When connectivity returns — ideally driven by an event rather than polling, as in event-driven communication — a sync worker drains the queue and reconciles with the backend.

This keeps four concerns cleanly separated: the user action, local persistence, synchronisation, and UI state. None of them block the others. The user never watches a spinner to create something; they create it locally and the app catches the server up later.

The traps

Offline-first is not free of sharp edges. The ones that bite:

  • Showing stale data as if it's fresh. Cache-first is great until the user is looking at a three-day-old price with no indication it's stale. For anything time-sensitive, surface freshness — a quiet "updated 2h ago", a subtle refreshing indicator — so cached isn't mistaken for current.
  • An unbounded cache. A store that only ever grows will eventually bloat the app and slow itself down. Decide on eviction — TTLs, size caps, or scope-based cleanup (drop a feature's cache when the user leaves it).
  • Sync conflicts. Two devices, or one device and the server, both changed the same thing while offline. Decide the rule up front — last-write-wins, server-wins, or a real merge — before the queue quietly overwrites someone's edit.
  • The infinite skeleton. If the first-ever load fails and there's nothing cached, a skeleton that never resolves is worse than an honest error with a retry. A skeleton is for loading, not for failed.
  • Cache-only that never updates. "Rarely changes" isn't "never changes." Give cache-only data a way to refresh — on app update, on a schedule, or on an explicit event — or it silently rots.

None of these are reasons to avoid the approach. They're the design decisions the approach forces you to make on purpose instead of by accident.

The point

The biggest mistake I still see is treating offline as an exception — a mode you detect and handle, bolted onto an app that otherwise assumes a perfect connection.

Flip it. Design the happy path so it already works without the network, and connectivity becomes an enhancement rather than a requirement. Once your UI reads from a single local source of truth, refreshes it cache-first, and covers the rare empty moment with a skeleton, three things you'd otherwise fight for — resilience, better UX, and faster perceived performance — come almost for free.

Whether your user is on fibre or a struggling 2G connection, they get the same app. Only the freshness of the data changes — never whether the app works at all.