Skip to content
Blog

Offline-first mobile architecture for emerging markets

Demi Thathsara6 min read
NoteThis post draws from designing the Banana Leaf restaurant management system. Read the full Banana Leaf case study

Introduction

Most mobile app tutorials assume something that most users around the world simply do not have: a reliable internet connection.

In major metropolitan markets (London, San Francisco, Sydney) that assumption is mostly safe. But step into a restaurant kitchen in Colombo during a lunch rush, or a market stall in Nairobi at peak trading hours, and the calculus changes entirely. Cell towers get overwhelmed. Wi-Fi routers die under load. Mobile data plans run dry at critical moments.

If your app requires a live connection to function, you are not building for these users. You are building for a subset of the world's population and calling it done.

Offline-first architecture flips this. Instead of treating offline as an edge case to handle gracefully, you treat connectivity as a bonus, a way to sync and share data that already exists locally. The result is an app that works the same way whether the user has five bars of signal or none.

This post walks through the core patterns: local-first data storage, conflict resolution strategies, and the sync engine that ties it all together.


The cost of assuming connectivity

Before diving into architecture, it is worth understanding what goes wrong when you build connectivity-dependent apps for markets where connectivity is unreliable.

Lost orders. In a system like Banana Leaf, each order placed through the mobile app represents real money. If the app requires a live Supabase connection to submit an order, any network hiccup during a busy service means orders vanish. Staff re-take the order manually, introducing errors. The kitchen gets duplicate or missing items. Trust in the system erodes fast.

Failed synchronisation cascades. When sync fails, state diverges. The kitchen display system shows stale data. The web admin dashboard shows a different picture. Staff make decisions based on conflicting information. These aren't just UX problems, they translate directly into wasted food, unhappy customers, and lost revenue.

Trust broken at the wrong moment. Restaurant service has a rhythm. When the app fails during that rhythm, at table turnover or during a rush, the staff don't give it another chance. They go back to paper. You have lost the adoption battle.

The math is simple: in any market where connectivity is unreliable during peak load, offline-first is not a nice-to-have. It is the baseline requirement for a system that gets used.


Local-first architecture pattern

The core principle of local-first architecture is straightforward: all operations write to local storage first. The network is used only for synchronisation, not for primary data access.

For a Flutter app backed by Supabase, this means:

  1. SQLite as the primary store. Every read and write goes through a local SQLite database. The UI never waits for a network response.
  2. A sync queue. Every local write is recorded in an operations queue. The sync engine processes this queue when connectivity is available.
  3. Supabase as the remote source of truth. Once synced, Supabase holds the canonical state. Other clients (the web admin, the Kitchen Display System) read from Supabase in real time.

Here is the core data flow in Dart:

dart
// Local repository, all UI reads and writes go here
class OrderRepository {
  final Database _db;
  final SyncQueue _syncQueue;

  Future<void> createOrder(Order order) async {
    // 1. Write to local SQLite immediately
    await _db.insert('orders', order.toMap());

    // 2. Enqueue sync operation, don't wait for network
    await _syncQueue.enqueue(SyncOperation(
      type: SyncOperationType.create,
      table: 'orders',
      data: order.toJson(),
      localId: order.id,
      createdAt: DateTime.now().toUtc(),
    ));
  }

  Future<List<Order>> getOrders() async {
    // Always read from local, instant, no network dependency
    final rows = await _db.query('orders', orderBy: 'created_at DESC');
    return rows.map(Order.fromMap).toList();
  }
}

The UI component calls orderRepository.createOrder(order) and gets an immediate response. The order appears in the list instantly. The sync happens in the background, invisibly to the user.


Conflict resolution strategies

Local-first architecture introduces a problem that network-first apps never face: what happens when two clients modify the same record while offline?

The answer depends on your domain. There is no universal solution.

Last-write-wins

The simplest approach: the most recent write wins. Every record carries a updatedAt timestamp. When syncing, compare the local timestamp against the remote timestamp. If local is newer, push local. If remote is newer, accept remote.

This works well for most restaurant data. Menu prices, item availability, table status: these have natural owners. The admin dashboard owns menu data. The kitchen display owns order status. Conflicts are rare.

dart
Future<void> resolveConflict({
  required Map<String, dynamic> localRecord,
  required Map<String, dynamic> remoteRecord,
}) async {
  final localTime = DateTime.parse(localRecord['updated_at'] as String);
  final remoteTime = DateTime.parse(remoteRecord['updated_at'] as String);

  if (localTime.isAfter(remoteTime)) {
    // Local wins, push to remote
    await _supabase
        .from(localRecord['table'] as String)
        .upsert(localRecord['data']);
  } else {
    // Remote wins, update local
    await _db.update(
      localRecord['table'] as String,
      remoteRecord['data'] as Map<String, dynamic>,
      where: 'id = ?',
      whereArgs: [localRecord['id']],
    );
  }
}

Manual resolution for high-stakes data

For order line items, where double-applying a modification could mean charging a customer twice, last-write-wins is not safe enough. Instead, use an operational log: record the operation itself, not just the resulting state.

dart
// Record the intent, not just the outcome
class SyncOperation {
  final String id;
  final SyncOperationType type; // create | update | delete
  final String table;
  final Map<String, dynamic> data;
  final String? previousData; // For update, what we're replacing
  final DateTime createdAt;
  final bool idempotencyKey; // Prevent double-apply on retry
}

With an idempotency key, re-applying the same operation (due to a retry) is safe, the server recognises it and returns the existing result without creating a duplicate.


The sync engine

The sync engine is the component that processes the operation queue and resolves conflicts. It runs as a background service in Flutter, triggered by connectivity changes and on a regular timer.

Key design decisions:

Exponential backoff. If a sync attempt fails (the server is unreachable, or returns an error), wait before retrying. Start at 5 seconds, double each attempt, cap at 5 minutes. This prevents hammering a struggling server during degraded connectivity.

Batch sync. Don't send one HTTP request per operation. Batch pending operations and send them together. For a busy restaurant, this can mean the difference between 300 requests and 3.

Selective sync. Not all data needs to be synced immediately. Order status changes need to hit the kitchen display fast. Menu changes can wait for the next batch window.

dart
class SyncEngine {
  final Duration _initialBackoff = const Duration(seconds: 5);
  final Duration _maxBackoff = const Duration(minutes: 5);
  int _failureCount = 0;

  Future<void> sync() async {
    try {
      final pending = await _syncQueue.getPendingOperations(limit: 50);
      if (pending.isEmpty) return;

      // Group by table for efficient batching
      final batches = _groupByTable(pending);

      for (final batch in batches.entries) {
        await _processBatch(batch.key, batch.value);
      }

      _failureCount = 0;
    } catch (e) {
      _failureCount++;
      final backoff = _calculateBackoff();
      await Future.delayed(backoff);
    }
  }

  Duration _calculateBackoff() {
    final backoff = _initialBackoff * (1 << _failureCount.clamp(0, 10));
    return backoff > _maxBackoff ? _maxBackoff : backoff;
  }
}

Supabase Realtime for inbound sync. While outbound sync uses the queue, inbound changes (from the web admin or KDS) arrive via Supabase Realtime WebSocket subscriptions. The mobile app subscribes to relevant tables and applies remote changes to the local SQLite database when they arrive.

dart
// Subscribe to real-time order updates
supabase
    .from('orders')
    .stream(primaryKey: ['id'])
    .listen((records) async {
      for (final record in records) {
        await _applyRemoteChange(record);
      }
    });

This gives you the best of both worlds: offline writes that work regardless of connectivity, and near-real-time inbound updates when connected.


Conclusion

Offline-first is not a feature you add at the end of a project. It is an architectural decision that shapes your data model, your sync strategy, and your conflict resolution approach from day one.

The patterns described here (SQLite as primary store, an operations sync queue, exponential backoff, batching, and Supabase Realtime for inbound changes) form a solid foundation for mobile apps in any market where connectivity cannot be assumed.

Designing the Banana Leaf system is what made the value of this architecture concrete. The aim is a restaurant ordering system that gets through peak service whatever the cell towers are doing at 1pm on a Saturday.

Read the Banana Leaf case study to see how these patterns shape its design.
Share this postXLinkedInEmail