FlutterOffline FirstDartApr 29, 20266 min read1344

Building Resilient Apps: A Guide to Offline-First Architecture in Flutter

We’ve all been there: you open an app on a train, in a concrete building, or in a remote area, and all you get is an endless loading spinner. Frustrating, right?

In today’s mobile ecosystem, users expect apps to be instantly responsive, regardless of their network status. This is where offline-first architecture comes in. Instead of treating the network as a given and offline mode as an edge-case error, offline-first flips the script. The local device becomes the primary source of truth, and the network is treated as an optional enhancement to sync data. According to the official Flutter architectural guidelines, there isn’t just one way to implement this. Depending on your app’s requirements, you can mix and match different reading and writing strategies. Let’s dive into how to architect your Flutter app for resilience.

1_7LavIQTMJYZbvXsoh1yJDA.webp

---

The Core Concept: The Repository Pattern

At the heart of Flutter’s offline-first architecture is the Repository Pattern. Repositories act as the single source of truth for your UI.

Instead of your UI talking directly to an API, it talks to a Repository. The Repository acts as a middleman, orchestrating two separate services:

1. ApiClientService: Handles remote HTTP REST calls to your backend.
2. DatabaseService: Manages local data persistence (using tools like Isar, Drift, or Hive).

By keeping these separated, your UI simply asks the Repository for data, completely unaware of whether it came from a local cache or a remote server.

---

Strategy 1: Reading Data

When it comes to reading data, your goal is speed and reliability. Depending on how crucial up-to-date data is for your app, you have three main approaches:

Approach A: Local Data as a Fallback

In this model, your app tries to fetch fresh data from the API first. If the request succeeds, it updates the local database and returns the data. If the network call fails (e.g., the device is offline), the app catches the error and serves the locally cached data.

  • Best for: Apps where showing the absolute latest data is highly preferred, but showing slightly stale data is better than an error screen.
  • Approach B: Using a Stream (The Best of Both Worlds)

    This is the gold standard for responsive UIs. The Repository returns a Stream that emits multiple values. First, it immediately yields the locally stored data. Because reading from a local database is blazing fast, the UI populates instantly. Meanwhile, a network call is made in the background. Once the fresh remote data arrives, the database is updated, and the Stream emits the new value, seamlessly updating the UI.

  • Best for: Social feeds, dashboards, and apps that prioritize instant load times but still need to eventually display the most up-to-date server state.
  • Approach C: Using Only Local Data

    In this highly decoupled approach, the UI only _ever_ reads from the local database. A completely separate sync() method is triggered (either manually via a pull-to-refresh, or automatically via a background timer) to fetch remote data and update the local database.

  • Best for: Apps like weather trackers or news readers where background syncs happen periodically, and the user doesn’t need to block their workflow waiting for a server response.
  • ---

    Strategy 2: Writing Data

    Writing data introduces a new challenge: what happens when the user makes a change while offline? You have two primary paths here:

    Approach A: Online-Only Writing

    In this strict approach, the app attempts to push the data to the API service first. _Only_ if the API call is successful does the app save the data to the local database.

  • The Pros: Guarantees your local database is never out of sync with the server.
  • The Cons: You lose the ability to write data while offline, breaking the core promise of an offline-first experience.
  • Approach B: Offline-First Writing

    This is the true offline-first experience. When a user creates a new record, write it immediately to the local database and flag it as “unsynced”. The UI reacts instantly. Afterward, a sync engine attempts to push the change to the API.

    class EntryRepository {
      final DatabaseService _localDb;
      final ApiClientService _remoteApi;
    
      Future<void> createEntry(Entry entry) async {
        // 1. Mark as unsynced
        final newEntry = entry.copyWith(isSynced: false);
        
        // 2. Save locally (UI updates instantly)
        await _localDb.saveEntry(newEntry);
        
        // 3. Attempt to sync to the remote server
        _syncWithServer(newEntry);
      }
    
      Future<void> _syncWithServer(Entry entry) async {
        try {
          await _remoteApi.postEntry(entry);
          // On success, update the local flag
          await _localDb.updateEntry(entry.copyWith(isSynced: true));
        } catch (e) {
          // Network failed. Do nothing. The background sync will catch it later.
          print('Network unavailable, will sync later.');
        }
      }
    }
    

    If you choose this route, you will need a Background Sync Engine. Using a package like connectivity_plus, you can listen for when the network is restored and push any pending items:

    Connectivity().onConnectivityChanged.listen((ConnectivityResult result) {
      if (result == ConnectivityResult.mobile || result == ConnectivityResult.wifi) {
         _pushUnsyncedData(); // Fetches all isSynced == false and pushes them
      }
    });
    

    ---

    Handling the Edge Cases

    While the concepts are straightforward, the implementation can get tricky. You’ll need to consider:

  • UI Feedback: Even though the UI updates instantly, you might want to show a subtle indicator (like a small cloud icon with a slash) to let the user know an item is pending synchronization.
  • Conflict Resolution: If a user edits a record offline, and someone else edits the same record online, who wins? You’ll need a strategy (like last-write-wins or versioning timestamps).
  • App Lifecycles: What happens if the user closes the app before the sync finishes? Tools like workmanager can help you run background sync tasks even when the Flutter engine is paused.
  • ---

    Conclusion

    Transitioning to an offline-first mindset changes how you build apps. It isn’t a single rigid pattern, but a collection of strategies — from Streams to background sync engines — that you can tailor to your app’s specific needs. It requires more boilerplate up front, but the payoff is massive. You end up with an app that feels lightning-fast, highly resilient, and respects the user’s data regardless of their physical location.

    References: Official Flutter Offline-First Guide

    Enjoyed this read?

    4likes
    Comments

    Loading…