All insights
Engineering6 min

Offline-first is a product decision

A cache is not an offline strategy. What it takes to make an app useful before the first network call — and the first-run bug we shipped while learning it.

By Muhammad Naeem

"Offline support" usually means a spinner that eventually turns into an error. A real offline-first product makes a different promise: it is useful before the network answers, and it tells the truth about what it knows.

That is a product decision, not a caching layer.

The promise you are making

Decide which of these you actually mean:

  • Read offline — the last known data is available.
  • Write offline — changes are queued and synced later.
  • First-run offline — the app is useful before it has ever reached the server.
  • Conflict-aware — two devices can change the same thing without silently losing one.

Each one adds cost. Most apps need the first. A field-operations app probably needs the second. A catalogue app needs the third. Very few need all four.

Write the promise down. Then design the states around it.

The four states

Every screen that depends on remote data has four states, not two:

  1. Fresh — data from the server, recent enough to trust.
  2. Stale — data from the server, older than the refresh window.
  3. Empty — no data yet, or genuinely no results.
  4. Failed — a request failed, and we know why (offline, auth, server).

The bug is usually in the overlap. An empty list looks like "no results" when it is actually "the first fetch has not finished". A stale catalogue looks fresh because the timestamp was written by the seed, not the server.

Design the copy and the action for each state before writing the fetch code.

The first-run problem

A new install has no cache. If the first screen waits for the network, the product is a spinner on the worst possible day.

BundleWala ships 71 plans in the app bundle. On first launch, the catalogue renders immediately, then the live API refresh replaces it. The user can browse, search and read activation codes before the first request completes.

That creates a subtle data-integrity problem: bundled data is not server data. If the app writes a "last fetched" timestamp when it loads the seed, it will believe it is fresh for the next six hours and skip the live refresh.

The fix is to treat the seed as a fallback, not a fetch. A seed version bump deletes the remote timestamp, so the next launch fetches the live catalogue immediately. The app can still show the seed while that happens.

The bug we shipped

For a while, a fresh install only refreshed carrier metadata on the first launch. The package refresh ran on the second launch. The app looked fine if you opened it twice; it looked broken if you only opened it once.

The cause was an early return. The launch routine checked whether the network metadata was outdated, refreshed /networks, and returned. The package refresh was below that return. On a mature install it never mattered, because the metadata was current. On a fresh install both conditions were true, and the cheap path won.

The fix was to run the full refresh first when the catalogue is stale or missing, then fall back to the metadata-only refresh. We added a regression test that asserts both the network and package calls happen on a fresh install.

The lesson is not "write tests". It is that offline logic has more branches than the UI suggests. The first-run path is a separate product.

What to persist

Persist the smallest set that makes the promise true:

  • the catalogue rows the user can browse;
  • the metadata needed to render them (network names, colours, logos);
  • the timestamp and version of the last successful server fetch;
  • the user's local changes, if writes are offline;
  • a schema version so you can migrate or discard safely.

Do not persist a timestamp you did not earn. Do not persist secrets. Do not make the cache the only copy of something the user cannot recreate.

Make staleness visible

A small, honest line is better than a silent lie:

Updated 2 hours ago · 286 plans

Tapping it refreshes. If the refresh fails, say so and keep the data. If the data is from the bundled seed, say that too, once, without making it feel broken.

The user does not need a sync diagram. They need to know whether the price in front of them is current.

When not to go offline-first

Offline-first is the wrong default when:

  • the data changes constantly and a stale answer is dangerous (prices with a short window, availability, balances);
  • the product is collaborative and conflicts are expensive;
  • the data is too large to bundle or store on the device;
  • the first-run experience is inherently online (upload, live call, real-time collaboration).

In those cases, be explicit: "This screen needs a connection." A clear limitation is better than a cache that pretends.

A checklist

  • [ ] The offline promise is written down.
  • [ ] The first-run path works with no network.
  • [ ] The seed does not masquerade as a fresh server fetch.
  • [ ] Fresh, stale, empty and failed states are designed.
  • [ ] The timestamp belongs to a successful server response.
  • [ ] There is a test for the first-run branch.
  • [ ] The UI tells the user how old the data is.
  • [ ] A failed refresh never deletes the last good data.

Offline-first is not a feature you add at the end. It is a decision about what the product is allowed to promise.

Written by Muhammad Naeem · 28 Sept 2026

Reply by email

Your turn

Have a problem worth writing about?

We like hard product problems. Tell us what you are working on.

Booking new projects for Q1 2027