DEV Community

Lacey Glenn
Lacey Glenn

Posted on

Your App Just Lost Wi-Fi. Now What? Building an Offline-First Web App

Service workers, IndexedDB, and Background Sync in one small, working pattern, with the failure cases most tutorials skip.

It's Friday evening, the shop is full, and the router just died.

If your app is a normal web app, the cashier now sees a spinner, then an error, then a queue of unhappy people. If you built it offline-first, nobody notices anything. Sales keep going through, and when the connection comes back, everything syncs quietly in the background.

The second version isn't magic. It's three browser features working together, plus one habit. Let's build it.

The mental model

Most tutorials teach these tools separately, which is why they're confusing. Here's what each one actually does:

  • Service worker: a script that sits between your app and the network. It makes sure the app itself loads with no internet.
  • IndexedDB: a database in the browser. It makes sure the data survives with no internet.
  • Background Sync: a way to say "run this when the connection returns, even if the tab is closed." It makes sure the upload eventually happens.

And the habit that ties them together:

Write locally first. Sync later. Never make the user wait on the network.

The UI talks only to IndexedDB. A separate process moves data between IndexedDB and the server. That single decision is what makes everything else easy.

Step 1: Make the app load offline

Register a service worker from your page:

// app.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}
Enter fullscreen mode Exit fullscreen mode

Then cache the "app shell" (the HTML, JS, and CSS needed to boot) when the worker installs:

// sw.js
const CACHE = 'shell-v1';
const SHELL = ['/', '/index.html', '/app.js', '/styles.css'];

self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
  self.skipWaiting();
});

self.addEventListener('activate', (e) => {
  // Delete old caches when you bump the version
  e.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
  self.clients.claim();
});

self.addEventListener('fetch', (e) => {
  const { request } = e;
  if (request.method !== 'GET') return;                   // only cache reads
  if (new URL(request.url).pathname.startsWith('/api/')) return; // API is handled by our own code

  // Cache first, network as fallback
  e.respondWith(caches.match(request).then((hit) => hit || fetch(request)));
});
Enter fullscreen mode Exit fullscreen mode

Two things worth noticing. First, we deliberately skip /api/ requests. Your data layer shouldn't be hidden inside a generic cache rule. Second, bumping shell-v1 to shell-v2 is how you ship updates, and it's the part people forget.

Turn off your Wi-Fi and reload. The app should still open.

Step 2: Store data locally with IndexedDB

The raw IndexedDB API is famously awkward, so use the tiny idb wrapper, which gives you promises:

import { openDB } from 'idb';

export const dbPromise = openDB('shop', 1, {
  upgrade(db) {
    db.createObjectStore('products', { keyPath: 'id' });
    db.createObjectStore('outbox', { keyPath: 'id' });
  },
});
Enter fullscreen mode Exit fullscreen mode

We have two stores. products is a local copy of data the app reads. outbox is a queue of things the user did that the server hasn't heard about yet.

Step 3: The outbox pattern

Here's the core idea. When the user does something (make a sale, save a note, submit a form), don't call the API. Write the action to the outbox and update the UI immediately.

export async function saveSale(sale) {
  const db = await dbPromise;

  await db.put('outbox', {
    id: crypto.randomUUID(),   // generated on the device, not by the server
    createdAt: Date.now(),
    payload: sale,
    status: 'pending',
  });

  requestSync();               // "please upload when you can"
}
Enter fullscreen mode Exit fullscreen mode

Notice the ID is generated on the client. That's essential, and we'll come back to why in a moment.

From the user's point of view, the sale is done the instant this line finishes. No spinner, no network.

Step 4: Upload with Background Sync

Now we need something that empties the outbox. Background Sync lets the browser call your service worker when connectivity returns:

// app.js
async function requestSync() {
  const reg = await navigator.serviceWorker.ready;

  if ('sync' in reg) {
    await reg.sync.register('flush-outbox');
  } else {
    flushOutbox();   // fallback, see below
  }
}

window.addEventListener('online', requestSync);
Enter fullscreen mode Exit fullscreen mode

And in the service worker:

// sw.js
importScripts('https://cdn.jsdelivr.net/npm/idb@8/build/umd.js');

async function flushOutbox() {
  const db = await idb.openDB('shop', 1);
  const items = await db.getAll('outbox');

  for (const item of items) {
    if (item.status === 'failed') continue;

    const res = await fetch('/api/sales', {          // a network error throws here
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': item.id,
      },
      body: JSON.stringify(item.payload),
    });

    if (res.ok) {
      await db.delete('outbox', item.id);            // success: remove from queue
    } else if (res.status >= 400 && res.status < 500) {
      item.status = 'failed';                        // bad data: retrying won't help
      await db.put('outbox', item);
    } else {
      throw new Error('Server error');               // 5xx: let the browser retry
    }
  }
}

self.addEventListener('sync', (e) => {
  if (e.tag === 'flush-outbox') e.waitUntil(flushOutbox());
});
Enter fullscreen mode Exit fullscreen mode

The important detail is throw. If the promise passed to waitUntil rejects, the browser treats the sync as failed and retries later with backoff. You get a retry loop for free.

Also note the split between error types. A network failure or a 5xx is temporary, so retry. A 4xx means the server rejected the data itself, and retrying forever would just block the queue. Mark it failed and surface it to a human.

Step 5: The bug that will bite you, duplicates

Imagine the request reaches the server, the server saves the sale, and the connection drops before the response arrives. Your app thinks it failed, so it retries. Now you've recorded the sale twice.

This is the reason for the Idempotency-Key header and the client-generated ID. The server must recognize a repeat:

// server (Express-style)
app.post('/api/sales', async (req, res) => {
  const key = req.get('Idempotency-Key');
  if (!key) return res.status(400).json({ error: 'Missing Idempotency-Key' });

  const existing = await db.sales.findByKey(key);
  if (existing) return res.status(200).json(existing);   // already processed, same answer

  const sale = await db.sales.create({ ...req.body, idempotencyKey: key });
  res.status(201).json(sale);
});
Enter fullscreen mode Exit fullscreen mode

Back this with a unique constraint on the key in your database. A check in code alone can still race when two requests arrive at the same moment.

With this in place, retries are always safe. You can now be aggressive about retrying, which is exactly what unreliable networks require.

The catch: Background Sync isn't everywhere

Here's the honest part. Background Sync works in Chromium-based browsers (Chrome, Edge, and most Android browsers), but Safari and Firefox don't support it. So it should be an enhancement, not the only mechanism.

A solid fallback uses events you can rely on everywhere:

window.addEventListener('online', flushOutbox);   // connection returns
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') flushOutbox();
});
flushOutbox();                                    // on every app start
Enter fullscreen mode Exit fullscreen mode

Put the flush logic in one plain script that both the page and the service worker can load, so you don't maintain two copies. With this, Chrome users get true background uploads, and everyone else syncs whenever the app is open. Neither group loses data.

Things that go wrong in production

1. Browsers can evict your storage. Under storage pressure, a browser may clear site data. Ask for durable storage so the outbox isn't the first thing to go:

if (navigator.storage?.persist) {
  await navigator.storage.persist();
}
Enter fullscreen mode Exit fullscreen mode

2. Updating the service worker is tricky. A new worker waits until old tabs close. Decide deliberately how you'll handle this, for example by showing an "Update available" prompt rather than silently swapping code mid-sale.

3. Schema changes. IndexedDB upgrades run on user devices you can't see. Write the upgrade function to handle every old version, not just the previous one.

4. Don't trust the device clock. Use createdAt for ordering on the device, but let the server assign the authoritative time.

5. Show sync status. A small "3 sales waiting to upload" indicator turns anxiety into information. It's the cheapest trust feature you'll ever add.

How to test it properly

  • In Chrome DevTools, open Application → Service Workers and tick Offline.
  • Use Network → Slow 3G to simulate a bad connection.
  • Under Application → Background Services → Background Sync, you can record and trigger sync events.
  • Most importantly, test the nasty case: kill the connection mid-request. That's where duplicate bugs hide.

The takeaway

Offline support isn't a feature you add at the end. It's a decision about where the source of truth lives while the user is working: the device first, the server eventually.

Once you make that decision, the rest follows. The UI talks to a local database. A queue carries changes upward. Idempotency makes retries safe. The service worker keeps the lights on.

The result is an app that treats a dead router as a minor inconvenience instead of an outage, and users who never learn how close it came to failing.


Want this as a Markdown file ready to paste into dev.to (with front-matter tags), or a companion repo layout with a minimal working demo?

Top comments (0)