Home
/
Blog
/
Blog article

3/27/2026

Building Offline-First Apps: Lessons From Real Production Code

I remember the exact moment I decided DocPilot had to work offline.

We were demoing the app to a small clinic in Hyderabad. Mid-demo, their Wi-Fi dropped. The app froze. The doctor looked at me with this expression — not angry, just resigned — like they'd seen this before. "So it doesn't work without internet?" They'd been burned by other software. We hadn't even shipped yet and we were already losing trust.

That was the moment DocPilot became an offline-first app. Not "works with slow internet" — actually, genuinely offline-first. A year later, clinics use it daily in areas with spotty connectivity and it just works. Here's what I learned.

The Problem With "Works Online" Apps

Most web apps are built assuming a network connection. They fetch data on load, post updates immediately, and when the connection drops — they break. For a todo app, that's annoying. For a clinic managing patient records and appointments, it's a real problem.

Offline-first means designing for the offline case upfront, not bolting it on as an afterthought. The network is an enhancement, not a requirement.

What Makes an App Truly Offline-First

Three things have to be true:

  • The app shell loads without a network (service workers)
  • The data is available locally (IndexedDB or similar)
  • Changes made offline sync back when the connection returns — without data loss

That third one is where it gets genuinely hard. Let's go through each layer.

Service Workers: Your Offline Safety Net

A service worker sits between your app and the network, intercepting requests. For DocPilot, I use a cache-first strategy for the app shell and API responses, with a background sync for mutations.

Here's a simplified version of how I register and precache the app shell:

// sw.js — simplified service worker for DocPilot
const CACHE_NAME = 'docpilot-v2';
const PRECACHE_URLS = ['/', '/index.html', '/main.js', '/styles.css'];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
  );
  self.skipWaiting();
});

self.addEventListener('fetch', (event) => {
  // Cache-first for GET, network-only for mutations
  if (event.request.method !== 'GET') return;

  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached;
      return fetch(event.request).then((response) => {
        // Clone and cache fresh responses
        const clone = response.clone();
        caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
        return response;
      });
    })
  );
});

In practice, I use Workbox to handle the boilerplate. But understanding the underlying mechanism matters when things go wrong — and they will.

IndexedDB: The Real Storage Layer

localStorage is synchronous and tiny. For a clinic app storing patient records, appointment history, and prescriptions, you need IndexedDB. It's async, supports structured data, and can store gigabytes. The raw API is verbose, so I use the idb library by Jake Archibald.

import { openDB } from 'idb';

const db = await openDB('docpilot', 1, {
  upgrade(db) {
    // Patients store with compound index
    const patients = db.createObjectStore('patients', { keyPath: 'id' });
    patients.createIndex('by-doctor', 'doctorId');

    // Pending sync queue
    db.createObjectStore('syncQueue', {
      keyPath: 'id',
      autoIncrement: true,
    });
  },
});

// Read patients instantly from local DB — no network needed
export async function getPatients(doctorId) {
  return db.getAllFromIndex('patients', 'by-doctor', doctorId);
}

// Write locally first, queue for sync
export async function createAppointment(data) {
  const id = crypto.randomUUID();
  const appointment = { ...data, id, syncStatus: 'pending', createdAt: Date.now() };

  await db.put('appointments', appointment);
  await db.add('syncQueue', { type: 'CREATE_APPOINTMENT', payload: appointment });

  return appointment;
}

The key insight here: every write goes to IndexedDB first and also gets added to a sync queue. The UI sees the change immediately. The server catches up when it can.

Sync Strategies: How to Get Data Back to the Server

There are a few approaches, and the right one depends on your use case.

Optimistic Updates

Show the user the result immediately, then sync in the background. If it fails, roll back. This is what DocPilot uses for most appointment bookings — the UX is instant, and the doctor doesn't wait for a round-trip.

Queue-Based Sync with Background Sync API

The Background Sync API lets you register sync events that fire when the user comes back online — even if they've closed the tab. I use this for DocPilot's sync queue:

// In your app — register for background sync when going offline
async function queueSync() {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-appointments');
  }
}

// In sw.js — handle the sync event
self.addEventListener('sync', async (event) => {
  if (event.tag === 'sync-appointments') {
    event.waitUntil(processSyncQueue());
  }
});

async function processSyncQueue() {
  const db = await openDB('docpilot', 1);
  const queue = await db.getAll('syncQueue');

  for (const item of queue) {
    try {
      await fetch('/api/sync', {
        method: 'POST',
        body: JSON.stringify(item),
        headers: { 'Content-Type': 'application/json' },
      });
      await db.delete('syncQueue', item.id);
    } catch (err) {
      // Leave it in the queue, will retry on next sync
      console.error('Sync failed for item', item.id, err);
    }
  }
}

Conflict Resolution: The Hard Part Nobody Talks About

Here's a real scenario from DocPilot: a doctor updates a patient's prescription on their phone (offline). Meanwhile, a nurse updates the same patient's appointment time on the clinic desktop (online). When the doctor's phone reconnects, who wins?

I've tried a few strategies:

  • Last-write-wins (timestamp) — simplest, but dangerous when clocks drift between devices.
  • Field-level merging — if different fields were changed, merge them. This works well for DocPilot because prescription changes and appointment changes are different fields.
  • CRDTs (Conflict-free Replicated Data Types) — mathematically guaranteed to converge. Overkill for most apps, but worth knowing about for collaborative real-time use cases.

For DocPilot, I landed on field-level merging with a version vector per record. Each change tracks which fields were modified and at what server-assigned sequence number. The server reconciles on sync — if two clients modified different fields, it merges. If the same field was modified, the server version wins and the client gets a conflict notification in the UI.

This isn't perfect, but perfect conflict resolution doesn't exist — you're always trading complexity for correctness. Pick the simplest strategy that fits your actual conflict rate.

UX for Offline States: Don't Leave Users in the Dark

Technical correctness isn't enough. Users need to know what state they're in. For DocPilot, I show:

  • A subtle "Offline" badge in the header when the app detects no connection (using the navigator.onLine event + a real ping check)
  • A pending indicator on records that haven't synced yet — doctors know which appointments exist only locally
  • A toast notification when sync completes — "3 appointments synced" — so the user feels confident the data made it
  • Explicit conflict notifications for the rare cases where manual resolution is needed

One thing I learned: don't disable UI when offline. Let users keep working. Gray out or badge things that require connectivity (like sending an external notification), but keep the core workflows running. The app should get out of the way.

Lessons From DocPilot in Production

After shipping this to real clinics and watching it get hammered in the wild, here's what I'd tell myself from the start:

  • navigator.onLine lies. It tells you if the device thinks it has a connection, not whether the server is actually reachable. Always do a real health check ping to confirm.
  • IndexedDB storage limits are real. Browsers can evict data when storage is low. Handle this gracefully — don't store things you can't re-fetch.
  • Sync queue debt compounds. If a device goes offline for 3 days and queues 200 mutations, syncing them all at once will hammer your server. Add rate limiting and batching to your sync process.
  • Test offline mode explicitly. Chrome DevTools has an offline mode in the Network panel. Use it constantly during development. It will catch issues your happy-path tests will miss.
  • Service worker updates are subtle. New service worker versions wait for existing ones to stop controlling pages. Plan your update strategy — skipWaiting() is an option but can cause issues if the client and server APIs are out of sync.

The Payoff Is Real

Building offline-first is more work upfront. There's no pretending otherwise. But the trust you build with users is worth it. Clinics using DocPilot don't worry about Wi-Fi anymore — they just work. A similar approach applies to any field-facing app: field service, logistics, sales tools, anything where the network isn't reliable. Over at Big Bear Software, offline capability has become part of how we evaluate whether an app design is truly resilient.

If you're building something that needs to work reliably in the real world, give offline-first a serious look. Check out my projects page to see what else I've shipped, or hit me up on the about page if you want to talk architecture. I'm always happy to dig into the hard parts.

Also worth reading: MongoDB Indexing — Is It Slowing Down Your App? — another lesson from building production apps.

Now go build something that survives the real world.

— Anurag