Connectivity in most African cities is not broken — it is unpredictable. A user in Kumasi or Lagos might have full 4G one moment and zero signal the next. If your mobile app silently fails or locks the UI behind a loading spinner every time the network drops, you are not building for your actual users. You are building for a demo.

Offline-first architecture flips the default assumption: the local device is the source of truth, and the server is a synchronisation target — not a gatekeeper.

Why SQLite Is the Right Foundation

SQLite ships with every Android and iOS device. It is fast, reliable, battle-tested, and requires zero infrastructure on the client side. For React Native projects, libraries like expo-sqlite or react-native-quick-sqlite provide low-overhead bindings. For Flutter, sqflite or drift handle the heavy lifting with typed query support.

The key principle: every user action writes to SQLite first. The network is never in the critical path for reads or writes.

Designing Your Local Schema for Sync

The biggest architectural mistake developers make is treating the local database as a simple cache. A proper offline-first schema requires a few extra columns on every synced table:

CREATE TABLE orders (
  id          TEXT PRIMARY KEY,       -- UUID, generated client-side
  payload     TEXT NOT NULL,          -- JSON blob or normalised columns
  created_at  INTEGER NOT NULL,       -- Unix timestamp (ms)
  updated_at  INTEGER NOT NULL,
  synced_at   INTEGER,                -- NULL = pending sync
  is_deleted  INTEGER DEFAULT 0,      -- Soft delete flag
  sync_error  TEXT                    -- Last error message, if any
);

A few design decisions deserve explanation:

  • Client-side UUIDs. Never rely on a server-generated auto-increment ID. If the record has not synced yet, it has no server ID. Generate UUIDs on the device at creation time — both sides can then reference the same identifier.
  • synced_at as a dirty flag. Any record where synced_at IS NULL or synced_at < updated_at is queued for upload. This single comparison drives your entire sync queue.
  • Soft deletes. Hard-deleting a row locally before it syncs means the server never learns about the deletion. Set is_deleted = 1 and let the sync process handle the actual removal on the backend.

Building the Sync Queue

Think of sync as a background job, not a network call tied to a UI action. Structure it in three phases:

Phase 1 — Push Local Changes

On connection detected, query all dirty records and batch them into a single HTTPS request. Batching is critical in low-bandwidth environments: one request with 50 records is far more efficient than 50 individual requests.

POST /api/sync/push
Body: { records: [...] }

The server should process each record, return per-record results (success, conflict, or error), and respond with server-assigned metadata like canonical timestamps.

Phase 2 — Handle Conflicts

Conflict resolution is where most implementations fall apart. The simplest reliable strategy is last-write-wins based on updated_at: whichever side has the more recent timestamp wins. For most mobile use cases — form submissions, orders, field data collection — this is acceptable.

For collaborative or financial data, you need something more deliberate. Consider storing a version integer alongside each record and rejecting server pushes that do not increment from a known version. Expose a conflict UI to the user only when the business logic demands it. Most users never need to see a merge screen.

Phase 3 — Pull Remote Changes

After pushing, fetch any changes from the server since the device's last known sync cursor:

GET /api/sync/pull?since=1718200000000&device_id=abc123

The server returns records updated after the since timestamp. Write them to SQLite, updating synced_at to mark them clean. Store the latest updated_at from the pull response as your new cursor in local preferences.

Connectivity Detection Done Right

NetInfo in React Native and Connectivity in Flutter can tell you when a network interface is available — but available is not the same as reachable. A device connected to a router with no upstream internet will appear online.

Use a lightweight heartbeat: a HEAD request to your API's /health endpoint every 30 seconds when the interface shows as connected. Only trigger a sync cycle when you get a 200 back. This avoids wasted retry storms on flaky connections.

Handling Low-Bandwidth Realities

A few additional tactics that make a measurable difference in sub-100 Kbps conditions:

  • Compress payloads. Enable gzip compression on your API. Most HTTP clients support it natively. Payload size reductions of 60–80% are typical for JSON.
  • Paginate pull responses. Never return unbounded result sets. A device syncing after a week offline should pull in pages of 200–500 records, not thousands at once.
  • Exponential backoff on failure. If a sync attempt fails, retry after 5 seconds, then 15, then 60. Never hammer a recovering network.
  • Prioritise critical data. Not all tables need to sync immediately. User profile updates can wait. A payment confirmation cannot. Assign sync priority levels to your tables and process high-priority queues first.

Testing Offline Behaviour

Turn off your WiFi and use your app for 20 minutes. Submit forms. Navigate. Create records. Then reconnect and watch the sync queue drain. If anything breaks, the bug is in your schema design or your dirty-flag logic — not in the network layer.

Use Android Studio's Network Profiler or Charles Proxy to simulate 2G speeds (50 Kbps, 500ms latency) and validate that your app remains usable, not just functional.

Why This Matters for Your Project

If you are building a mobile product for markets where connectivity is a variable rather than a constant — field sales tools, healthcare data collection, logistics tracking, fintech — offline-first is not an optional enhancement. It is table stakes. Getting the SQLite schema and sync architecture right from day one is dramatically cheaper than retrofitting it into an always-online app after launch. The patterns above scale from a solo founder's MVP to a production system handling tens of thousands of field agents, because the core principle never changes: trust the device, sync when you can, never block the user.