← All posts

5 min read

Building Offline-First Flutter Apps: Caching Strategies That Actually Work

How I design Flutter apps that keep working without a connection — local caching, stale-while-revalidate, queued writes and honest UI states.

  • Flutter
  • Offline
  • Architecture
  • Performance
Cover illustration for Building Offline-First Flutter Apps: Caching Strategies That Actually Work

Most apps are built on fast office Wi-Fi and used on trains, in basements, in parking garages and in cars. When I built Nightingale, an in-car audio app, “no signal” wasn’t an edge case — it was Tuesday. Drivers pass through tunnels and dead zones constantly, and the app had to keep playing.

That project changed how I approach every Flutter app. This post covers the patterns I now use by default to make apps offline-first: the app reads from local storage first, talks to the network second, and never leaves the user staring at a blank screen.

Online-first vs offline-first

In a typical online-first app, a screen does this:

  1. Show a spinner.
  2. Call the API.
  3. Render the response (or an error).

In an offline-first app, the order flips:

  1. Render whatever is in the local cache — immediately.
  2. Fetch fresh data in the background.
  3. Update the cache, and the UI updates because it’s watching the cache.

The difference to the user is huge. The first version shows a spinner on every open and fails completely without a connection. The second shows content instantly and quietly refreshes it.

Step 1: Make the local database the source of truth

The key idea is that the UI never reads from the network directly. It reads from a local store, and the network’s only job is to keep that store up to date.

For structured data I usually reach for Drift (SQLite with type-safe queries and reactive streams). For simple key-value caching, Hive or even shared_preferences is enough.

A repository ties it together:

class EpisodeRepository {
  EpisodeRepository(this._api, this._db);

  final EpisodeApi _api;
  final AppDatabase _db;

  /// The UI listens to this. It emits cached data right away,
  /// then again whenever a refresh writes new rows.
  Stream<List<Episode>> watchEpisodes() => _db.watchEpisodes();

  /// Pull fresh data and write it into the cache.
  /// Failures are swallowed here — the cache still has the last good data.
  Future<RefreshResult> refresh() async {
    try {
      final fresh = await _api.fetchEpisodes().timeout(const Duration(seconds: 10));
      await _db.replaceEpisodes(fresh);
      return RefreshResult.updated;
    } on TimeoutException {
      return RefreshResult.offline;
    } on SocketException {
      return RefreshResult.offline;
    }
  }
}

The screen subscribes to watchEpisodes() and calls refresh() on open and on pull-to-refresh. It doesn’t need to know or care whether the data came from the network five seconds ago or five days ago.

Step 2: Stale-while-revalidate

The pattern above is known as stale-while-revalidate: show what you have, then revalidate it. Two details make it feel right:

Show freshness honestly. If the data is old, say so. A small “Updated 3 hours ago” label under the list builds trust. Pretending cached data is live does the opposite.

Don’t refresh too eagerly. If the data was fetched 30 seconds ago, don’t fetch it again just because the user switched tabs. I store a lastFetchedAt timestamp per collection and skip refreshes inside a short window:

Future<void> refreshIfStale({Duration maxAge = const Duration(minutes: 5)}) async {
  final last = await _db.lastFetchedAt('episodes');
  if (last != null && DateTime.now().difference(last) < maxAge) return;
  await refresh();
}

Step 3: Queue writes instead of failing them

Reading offline is the easy half. Writing offline is where most apps give up and show “No internet connection”.

The approach I use is an outbox: when the user does something that changes data — likes an item, submits a form, marks something complete — the app:

  1. Applies the change to the local database immediately (an optimiztic update), so the UI reflects it.
  2. Writes a pending operation to an outbox table.
  3. A sync worker drains the outbox whenever the network is available.
Future<void> markAsPlayed(String episodeId) async {
  await _db.transaction(() async {
    await _db.setPlayed(episodeId, true);
    await _db.enqueue(PendingOp(
      type: 'mark_played',
      payload: {'episodeId': episodeId},
      createdAt: DateTime.now(),
    ));
  });
  _sync.kick(); // try now; if offline, it'll retry later
}

Doing both writes in a single transaction matters: you never end up with a local change that has no matching outbox entry, or vice versa.

The sync worker processes operations in order, deletes each one on success, and backs off on failure. Operations should be idempotent on the server — sending “mark episode 42 as played” twice should be harmless — because on a flaky network you will send some of them twice.

Step 4: Know when you’re back online

The connectivity_plus package tells you when the device’s network interface changes:

Connectivity().onConnectivityChanged.listen((results) {
  if (!results.contains(ConnectivityResult.none)) {
    syncWorker.kick();
  }
});

One important caveat: connectivity is not the same as internet access. A phone can be connected to Wi-Fi that has no internet (hotel captive portals are the classic example). Treat connectivity changes as a hint to try syncing, and treat the actual request result as the truth.

Step 5: Cache media, not just data

For apps with images or audio, caching JSON isn’t enough — the list loads offline but every thumbnail is a grey box.

  • For images, cached_network_image handles disk caching with almost no setup.
  • For audio or large files, download to the app’s documents directory and store the local path in your database. Play from the local file when it exists; stream when it doesn’t.
  • Put an upper bound on cache size and evict the least recently used files. Users notice when your app quietly eats 4 GB of storage.

Step 6: Design the UI for all the states

Offline-first changes what “error” means. The network failing is no longer an error if you have cached data — it’s just a status. My screens usually end up with these cases:

Cache Network What the user sees
Has data Refresh succeeds Fresh content
Has data Refresh fails Cached content + subtle “offline” banner
Empty Refresh in progress Skeleton loader
Empty Refresh fails Full-screen message with a retry button

Only the last row is a real error state. In a well-cached app, users rarely see it.

Testing offline behavior

Offline bugs hide from normal testing because developers are always online. A few habits help:

  • Turn on airplane mode mid-action: halfway through a form submit, during a list refresh, while audio is buffering.
  • Use the network throttling built into Android emulators and the iOS Network Link Conditioner to simulate slow, lossy connections — those are often worse than no connection at all.
  • Write a unit test for the repository with a fake API that throws SocketException, and assert the stream still emits cached data.

Takeaways

  • The UI reads from the local database; the network only updates it.
  • Show cached data instantly and refresh in the background.
  • Queue writes in an outbox and sync them when you can; make server operations idempotent.
  • Connectivity events are hints, not guarantees.
  • Only show a full error screen when there’s genuinely nothing to show.

Offline-first takes more thought up front, but it removes an entire category of one-star reviews. It’s one of the reasons I say most app failures aren’t about the code — they’re about what happens between the code and a user with one bar of signal.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).