Skip to content

Building Offline-First Web Apps: Lessons From Kenya

How to build web apps that keep working when the network does not: service workers, IndexedDB, a durable sync queue, and conflict resolution, drawn from shipping software for real Kenyan connectivity.

By Karani Geoffrey, Founder & CEO, Upeosoft
In short

Building offline-first means treating the network as an optimisation, not a requirement. You cache the app shell with a service worker, store data in IndexedDB, write user actions to a durable local queue, and sync to the server when connectivity returns, with explicit conflict resolution. The app stays fully usable while offline.

Key takeaways
  • Offline-first is an architecture decision, not a feature you add later; retrofitting it is far harder than designing for it.
  • A service worker caches the app shell so the interface loads instantly with zero network.
  • IndexedDB, not localStorage, is the right store for structured offline data and large datasets.
  • User actions go into a durable outbox queue and replay to the server when the connection returns.
  • Conflict resolution must be an explicit design choice; last-write-wins silently destroys data.
  • In the field, intermittent and flaky connectivity is harder to handle than being fully offline.

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.

Frequently asked questions

What is the difference between offline-capable and offline-first?

Offline-capable means an online app degrades gracefully when the network drops. Offline-first inverts the default: the local device is the primary source of truth and the app is built to run entirely from local storage, treating the server as something it syncs with when it can. The second approach produces far more reliable field software.

Should I use localStorage or IndexedDB?

Use IndexedDB for any real offline app. localStorage is synchronous, capped at a few megabytes, and stores only strings, which makes it unsuitable for structured records or large datasets. IndexedDB is asynchronous, transactional, and can hold hundreds of megabytes of structured objects, which is what an offline data layer actually needs.

How do I handle conflicts when two devices edit the same record?

Decide the strategy deliberately per data type. Options include last-write-wins with timestamps, field-level merging, or queuing conflicts for human review. The dangerous default is silent last-write-wins on the whole record, which quietly overwrites a colleague's changes. Track a version or updated timestamp on every record so the server can detect conflicts rather than ignore them.

Do I need a service worker to go offline-first?

For a web app, effectively yes. The service worker is what lets the app shell and assets load with no network, and it can intercept requests to serve cached responses. Without it, a browser refresh while offline simply fails to load the page. Data persistence uses IndexedDB, but the service worker is what makes the app itself launchable offline.

How do you test offline behaviour realistically?

Do not rely only on the browser's offline toggle, which simulates a clean on-or-off state. Real networks fail messily: high latency, partial responses, and connections that drop mid-request. Test with throttling, forced mid-sync disconnects, and airplane-mode cycles on real devices. The intermittent cases are where naive implementations corrupt data, so they deserve the most testing.

Karani Geoffrey
Karani Geoffrey
Founder & CEO, Upeosoft

Karani Geoffrey is the Founder & CEO of Upeosoft, a software and automation company rooted in Kenya. He builds custom software, AI systems, and production-grade ERPNext for businesses across East Africa, and writes about the Kenyan realities - eTIMS, M-Pesa, SHIF, unreliable internet and power - that make or break real systems.

Next step

Want this working in your business?

Upeosoft builds and hardens the systems behind this article - for real Kenyan operations, with eTIMS, M-Pesa and offline realities handled.

Keep reading

For Builders

How to Add AI to an Existing Business Application

A pragmatic engineer's guide to adding AI to software you already run: choosing a use case that pays off, using hosted APIs and retrieval over your own data, and building for cost, latency, and graceful failure.

8 min readRead article →
For Builders

Vue 3 and Frappe: Building a Modern SPA on an ERP Backend

How to put a modern Vue 3 single-page application in front of a Frappe or ERPNext backend: authentication, the REST API, the frappe-ui resource layer, routing, and shipping it as a bundled custom app.

8 min readRead article →
For Builders

What Is Frappe? The Framework Behind ERPNext, Explained

Frappe is the full-stack, metadata-driven Python and JavaScript framework that powers ERPNext. Here is what it actually is, how its core pieces fit together, and when it is the right foundation to build on.

8 min readRead article →