Skip to main content
Back to blog

Offline-first apps: harder than it sounds

·6 min readWeb Dev

Most apps treat offline as an error state. You lose connectivity, you get a spinner or a sad dinosaur, and you wait. Offline-first apps flip that assumption: the app works fully without internet, stores data locally, and syncs when connectivity returns. It sounds simple. It is not.

I have been thinking about this a lot while building Landbound. Trip planning is a textbook offline-first use case. You plan routes and mark waypoints at home with Wi-Fi, then you need all of that data in the field where there is no signal. If the app only works online, it is useless exactly when you need it most.

What offline-first actually means

There is a big difference between "works offline" and "offline-first." A cached page that shows stale data is not offline-first. A read-only snapshot is not offline-first. Offline-first means the app is fully functional without a network connection. You can create, read, update, and delete data. The local copy is the source of truth. When you get back online, changes sync to the server and to other devices.

That last part is where everything gets complicated.

Why it is genuinely hard

The core problem is conflict resolution. Imagine two people on a trip edit the same waypoint while both are offline. One changes the name, the other changes the coordinates. When both devices come back online, which version wins? What if one person deleted it entirely?

This is not a theoretical problem. It happens constantly in any multi-device or multi-user offline app.

graph TD
    A[Device A — offline edits] --> C[Sync Layer]
    B[Device B — offline edits] --> C
    C --> CR[Conflict Resolution]
    CR --> S[(Consistent State)]
    S --> A
    S --> B

And the solutions are not obvious:

  • Last-write-wins is the simplest approach, but it silently discards changes. Users lose work without knowing.
  • Manual merge prompts ("which version do you want to keep?") interrupt the user and require them to understand what changed.
  • Automatic merging requires understanding the structure of your data well enough to merge field-level changes without breaking anything.

Beyond sync conflicts, there are practical constraints. Browser storage via IndexedDB is limited and varies by browser. Safari is notoriously aggressive about evicting stored data. Service Workers have their own lifecycle quirks. And testing offline behavior is painful because you need to simulate various network states, partial connectivity, and devices that have been offline for different amounts of time.

The technical building blocks

For web apps, the foundation is Service Workers and the Cache API. A Service Worker intercepts network requests and can serve cached responses when the network is unavailable. This handles the "app still loads" part. The Cache API stores the app shell and static assets.

For data, IndexedDB is the main option in browsers. It is a key-value store that can hold structured data and binary blobs. The API is ugly, but libraries like Dexie.js make it tolerable.

The hard part, sync, is where the interesting tools live:

CRDTs (Conflict-Free Replicated Data Types) are data structures designed so that concurrent edits from multiple devices always converge to the same result without coordination. Libraries like Yjs and Automerge implement CRDTs for JavaScript. They are powerful but add complexity and have a learning curve.

PouchDB/CouchDB is the classic combination. PouchDB runs in the browser and syncs with a CouchDB server. The sync protocol handles conflict detection, and CouchDB stores conflicting revisions so you can resolve them. This has been around for years and works well, but CouchDB is not the most popular database to operate.

Newer options like ElectricSQL and PowerSync take a different approach. They sync data between a Postgres database on the server and SQLite on the client. You write normal SQL on both sides, and the sync layer handles the rest. This is appealing because most developers already know Postgres, and SQLite is rock-solid for local storage.

The Landbound connection

For Landbound, offline support is not a nice-to-have. It is the whole point. You build a trip at home: routes, waypoints, notes, downloaded map tiles. Then you go into the backcountry. If the app requires internet to show your planned route, you might as well have printed it on paper.

The approach I have been working toward is sync from Postgres on the server to SQLite on the device. Map tiles get cached separately since they are large binary blobs that do not need conflict resolution. Route and waypoint data syncs bidirectionally so you can add or edit waypoints in the field and have them appear on all your devices when you get home.

The tricky part is deciding the granularity of sync. Do you sync entire trips? Individual waypoints? Every field on every waypoint? Finer granularity means better conflict resolution but more sync overhead. Coarser granularity is simpler but means more conflicts.

For most apps, do not bother

I want to be honest about this. For most web applications, offline-first is overkill. If your users are always on Wi-Fi or cellular, the engineering cost of building robust offline support is not justified. A simple "you appear to be offline" message is fine.

But there are categories where offline-first is essential: outdoor and adventure apps, travel apps for regions with poor connectivity, field work applications (construction, agriculture, inspections), apps targeting the developing world where internet access is intermittent, and any tool where people need to work during flights or commutes.

If you are in one of those categories, my advice is to not try to build offline-first from day one. Start with a good online app. Get the data model right. Then add offline capabilities incrementally. Cache the app shell with a Service Worker. Add IndexedDB for local data. Then tackle sync. Each step adds value on its own, and you learn what your actual sync conflicts look like before you have to solve them.

Progressive enhancement is not just a frontend philosophy. It works for offline support too.

Sources

Enjoying the blog? Subscribe via RSS to get new posts in your reader.

Subscribe via RSS