Offline-First Flutter Apps That Sync in Seconds: Building a Delta-Sync Engine with Hive + Supabase

How I built a zero-latency community directory app that minimizes cellular data using a custom delta-sync mechanism.

Every Flutter developer knows this moment: your app works perfectly on office Wi-Fi, then a user opens it on a shaky 3G connection in a moving car… and stares at a spinner for 15 seconds. Or worse — the request fails entirely.

I hit this problem head-on while building Neighborhood Guide (دليل الضاحية), an offline-first community directory that connects residents with local services, duty pharmacies, and businesses. Most users would open it on mobile data, often weak mobile data. Downloading the entire dataset on every cold start was not an option.

The solution: local caching with Hive + a custom delta-sync engine on top of Supabase. Here's exactly how it works, and the mistakes I made along the way.

The Problem with the "Naive" Approaches

When developers say "offline-first," they usually mean one of three things:

  1. Cache everything on first load, refresh manually — works until your data changes. Users see stale content and blame you.
  2. Pull-to-refresh fetches the full dataset — simple, but wastes bandwidth and gets slower as your data grows. A directory with 2,000 businesses becomes a 4 MB JSON payload.
  3. Firestore offline persistence — works, but locks you into Firebase pricing and its caching model.

I wanted something better: instant UI from local storage, tiny sync payloads, and data that stays fresh in the background.

The Architecture in One Sentence

The app renders exclusively from a local Hive cache; a delta-sync engine quietly fetches only what changed since the last sync and merges it in.

┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ Supabase │ ◄────── │ Delta-Sync │ ◄────── │ Hive Cache │ │ (Postgres) │ changes│ Engine │ merge │ (local) │ └─────────────┘ └──────────────┘ └──────┬──────┘ │ ┌─────────▼────────┐ │ Flutter UI │ │ (reads Hive only)│ └──────────────────┘ 

Key decision: the UI never talks to the network directly. Widgets read from Hive; the sync engine is the only component that touches Supabase. This decoupling is what makes the UI zero-latency — rendering is a pure local read measured in milliseconds.

Step 1: The Server Side — Make Your Data "Syncable"

Delta sync needs one thing from your database: a reliable way to ask "what changed since time X?"

Add an updated_at column (and a soft-delete flag) to every synced table:

create table businesses ( id uuid primary key default gen_random_uuid(), name text not null, category text not null, is_open boolean default false, deleted boolean default false, updated_at timestamptz not null default now() ); 

The updated_at is maintained automatically with a trigger:

create or replace function touch_updated_at() returns trigger as $$ begin new.updated_at = now(); return new; end; $$ language plpgsql; create trigger trg_touch before update on businesses for each row execute function touch_updated_at(); 

Why soft deletes (deleted flag)? If a business is removed from the directory, a hard DELETE would leave clients with an orphaned local copy forever. A tombstone row travels through the sync like any update, and clients purge it locally.

Step 2: The Sync Engine (Dart)

The whole engine fits in one service class. The two pieces of local state it needs:

  • A Hive box holding the synced records.
  • A single timestamp: lastSuccessfulSync.
class DeltaSyncEngine { final SupabaseClient _supabase; final Box<Business> _cache; final Box _meta; // stores lastSuccessfulSync DateTime? get _lastSync => _meta.get('last_sync') as DateTime?; Future<void> saveLastSync(DateTime t) => _meta.put('last_sync', t); /// The app calls this on startup and after any write. Future<SyncResult> sync() async { final startedAt = DateTime.now(); final lastSync = _lastSync; try { var query = _supabase .from('businesses') .select(); // Delta mode: only fetch what changed since last sync. // First run (lastSync == null) does a full bootstrap. if (lastSync != null) { query = query.gt('updated_at', lastSync.toIso8601String()); } final rows = await query.order('updated_at'); int applied = 0, purged = 0; for (final row in rows) { final id = row['id'] as String; if (row['deleted'] == true) { await _cache.delete(id); purged++; } else { await _cache.put(id, Business.fromJson(row)); applied++; } } // CRITICAL: save the timestamp from BEFORE the request started, // not now() — otherwise updates arriving during the sync // would be skipped forever. await saveLastSync(startedAt); return SyncResult(applied: applied, purged: purged, failed: false); } catch (e) { // Offline? Server down? The local cache still serves the UI. // Just retry with backoff later — nothing is lost. return SyncResult(applied: 0, purged: 0, failed: true); } } } 

The bug that taught me the most

My first version saved DateTime.now() after applying changes. Sounds harmless — it's actually a classic race condition. Consider this timeline:

  1. Sync request starts at 10:00:00, asks for changes since 09:00:00.
  2. At 10:00:01, another user edits a business (its updated_at becomes 10:00:01).
  3. The response (generated at 10:00:02) doesn't include that edit.
  4. I save lastSync = 10:00:02.
  5. Next sync asks "since 10:00:02?" — the edit from 10:00:01 is never fetched again.

Saving the request-start timestamp closes the gap: anything that happened during the sync is simply included next time.

Step 3: The UI Reads Local — Always

class BusinessListCubit extends Cubit<BusinessListState> { final DeltaSyncEngine _sync; final Box<Business> _cache; Future<void> load() async { // 1. Emit cached data instantly — zero latency. final cached = _cache.values.toList() ..sort((a, b) => a.name.compareTo(b.name)); emit(BusinessListLoaded(cached, isSyncing: true)); // 2. Sync in the background, re-emit when fresh data lands. final result = await _sync.sync(); if (!result.failed) { final fresh = _cache.values.toList() ..sort((a, b) => a.name.compareTo(b.name)); emit(BusinessListLoaded(fresh, isSyncing: false)); } } } 

The user sees content immediately — even in airplane mode — and it silently upgrades to fresh data when the sync completes. No spinners blocking the screen.

Handling Offline Writes (Optional But Worth It)

For read-heavy apps like a directory, the pattern above is enough. If your users create or edit data offline, queue the mutations locally:

class MutationQueue { final Box<Map> _pending; // Hive box of unsent mutations Future<void> enqueue(Mutation m) async { await _pending.add(m.toJson()); await _flush(); // try sending immediately } Future<void> _flush() async { while (_pending.isNotEmpty) { try { final m = Mutation.fromJson(_pending.getAt(0)!); await _supabase.from(m.table).upsert(m.payload); await _pending.deleteAt(0); } catch (_) { break; // still offline — retry on next connectivity change } } } } 

A connectivity_plus listener triggers _flush() when the network returns. Conflict resolution here is deliberately simple — last write wins works for a directory; your domain might need version vectors or CRDTs (know when you actually need them).

Results

  • Cold start to interactive UI: under 100 ms in release mode (pure local read).
  • Typical sync payload after first launch: 5–50 KB instead of the full dataset.
  • Cellular data usage: dropped by roughly 95% for daily active users.
  • Airplane mode: the app is fully browsable; changes sync on reconnect.

Key Takeaways

  1. UI reads local cache only. The network is an implementation detail of the sync engine, not of your widgets.
  2. Timestamps must be captured at request start, or you create silent sync gaps.
  3. Soft deletes, not hard deletes — tombstones propagate deletion to offline clients.
  4. Sync failures are normal. Treat offline as a state, not an error; always leave the cache serving the UI.
  5. Resist over-engineering. You don't need CRDTs for a business directory. Ship the simple version, measure, then complicate.

I'm Laith, a software engineer building offline-first Flutter apps and Node.js backends. The full project (including the admin moderation queues secured with Supabase Row Level Security) is on my GitHub: github.com/laith-alskaf. Questions and pushback welcome in the comments.