Why offline-first is an architecture, not a feature
The biggest mistake teams make is treating offline as something to add near launch. It is not a feature; it is a decision about where the source of truth lives, and it shapes your entire data layer. Retrofitting offline into an app that assumes a live server is usually a rewrite, because every screen was built to fetch on demand and every action assumed an immediate response.
Offline-first inverts the assumption. The device is the primary store, the UI reads and writes locally and instantly, and the network is a background process that reconciles local state with the server when it can. Once you internalise that inversion, the rest of the design follows from it. In Kenya, where a field officer may spend a full day out of coverage, this is not an edge case to tolerate; it is the normal operating condition to design for.
The service worker: your offline app shell
A service worker is a script the browser runs separately from the page, able to intercept network requests even when the tab is closed. Its first job in an offline-first app is to cache the app shell, the HTML, JavaScript, and CSS that make up the interface, so the app launches instantly with no network at all.
The common pattern is cache-first for static assets and stale-while-revalidate for content that can be slightly out of date. You precache the shell during the service worker's install event, then serve those assets from the cache on every load, updating in the background when the network is available.
The subtlety that bites teams is cache versioning. If you do not name and clean up old caches on activation, users get stuck on stale builds. Give each deployment a cache version, and delete outdated caches in the activate event so upgrades roll out cleanly.
IndexedDB: the local data layer
Service workers handle the app; IndexedDB handles the data. It is the browser's transactional, asynchronous database, capable of storing structured objects and large datasets, which is exactly what an offline app needs and what localStorage cannot provide.
The raw IndexedDB API is verbose and event-based, so most teams wrap it. A thin library keeps the code readable while preserving the transactional guarantees that matter when a sync is writing many records at once. Model your local stores to mirror the records your app works with, and keep indexes on the fields you filter and sort by, because querying is where a naive offline store gets slow.
Treat IndexedDB as the app's real database while offline. Screens read from it and write to it, and they should never block waiting on the network. The server becomes a thing you sync with, not a thing you fetch from on every interaction.
- Store structured records, not just strings, with proper indexes for query performance.
- Wrap the raw API with a thin library to keep transactional code readable.
- Read and write locally so the UI stays instant regardless of connectivity.
- Keep a version or updated timestamp on each record to support sync and conflict detection.
The sync queue: a durable outbox
The heart of an offline-first app is the sync queue, an outbox that records every change the user makes so it can be replayed to the server later. When a user creates or edits a record offline, you write the change to IndexedDB immediately and append an entry to the outbox describing that operation.
When connectivity returns, a sync process drains the queue in order, sending each operation to the server and removing it on success. Two properties are essential. First, durability: the queue must survive app restarts and device reboots, so it lives in IndexedDB, not in memory. Second, idempotency: because a request can succeed on the server but fail to acknowledge before the connection drops, the server must handle a replayed operation without creating duplicates, usually by having the client assign a stable unique id to each operation or record.
Get these two right and the sync becomes robust to exactly the messy, half-completed network conditions that define real field use.
Conflict resolution you can defend
The moment two devices can edit the same record while offline, you have a distributed data problem, and pretending otherwise is how offline apps lose data. Conflict resolution must be an explicit, per-data-type decision.
Last-write-wins is the simplest strategy: attach a timestamp and let the latest edit win. It is acceptable for low-stakes fields but silently discards a colleague's work, so it is dangerous as a blanket default. Field-level merging, where non-overlapping edits to different fields of the same record both survive, is safer for forms. For high-value data, the right answer is often to detect the conflict on the server using a version number and surface it to a human rather than resolving it automatically.
The non-negotiable is detection. Every record carries a version or updated timestamp, and the server compares it on write so it can tell a clean update from a conflicting one. What you do after detecting is a product decision; failing to detect at all is a bug.
Field lessons: flaky beats fully offline
The counterintuitive lesson from shipping in Kenya is that fully offline is the easy case. When there is clearly no network, the logic is clean: work locally, queue everything. The hard case is intermittent connectivity, the connection that flickers on for two seconds, drops mid-request, and returns with high latency.
These conditions break naive code that checks navigator.onLine once and trusts it, because the browser reports online while requests still time out. The defences are practical: set aggressive timeouts so a hung request does not freeze a sync, retry with backoff, make every synced operation idempotent so a retry cannot duplicate data, and treat a failed request as a signal to requeue rather than to discard.
We also design the UI to tell the truth about sync state. Users trust an app that clearly shows what is saved locally, what is pending upload, and what has reached the server far more than one that hides the machinery and occasionally loses their work.
Pulling it together for real deployments
A dependable offline-first app is the sum of four cooperating parts: a service worker that makes the app launchable with no network, IndexedDB as the local source of truth, a durable idempotent outbox that replays changes, and an explicit conflict strategy that detects and resolves collisions. Miss any one and the reliability collapses in the field.
When the backend is Frappe, the sync target is its REST API, and the same idempotency and versioning discipline applies: stable client-assigned ids, a version field per doctype record, and server logic that treats a replay as an update rather than a new insert. The web layer stays framework-agnostic; what matters is the contract between the outbox and the server.
At Upeosoft we build this way because Kenyan field realities demand it. Connectivity is a variable, not a guarantee, and software that assumes otherwise fails the people who need it most. Designing for offline from the first line is not gold-plating; it is the difference between a tool that works on the ground and a demo that only works in the office.
