Skip to main content
Back to Blog
PWAService WorkersOffline-FirstWeb PerformanceCachingProgressive Web AppsFrontendMobile Web

Offline-First Web Apps: Building Resilient Progressive Web Apps (PWAs) that Work on Flaky Networks

Learn how to build offline-first Progressive Web Apps using service workers, smart caching strategies, and resilient UX patterns that keep users productive even on unreliable or slow networks.

April 21, 202615 min readNiraj Kumar

Picture this: a field technician in a remote warehouse opens your web app to log an inventory update. The signal drops. Your app throws a blank screen, a cryptic error, or worse — silently loses their data. They give up. Trust erodes. A native app download wins.

This is a solvable problem. Offline-first architecture flips the assumption: instead of treating the network as a given and offline as an exception, you treat local storage as the source of truth and sync when the network allows. The result is an app that feels fast, reliable, and respectful of your users' real-world constraints.

In this guide, we'll cover everything you need to build a production-grade offline-first PWA — from service worker fundamentals to caching strategies, background sync, and the UX nuances that separate a polished experience from a broken one.


What Is Offline-First, Really?

Offline-first doesn't just mean "my app doesn't crash without internet." It means:

  • The app loads immediately from a local cache — no network round trip required
  • Users can read, interact with, and write data while offline
  • Changes are queued locally and synced automatically when connectivity returns
  • The UI communicates network state clearly, without panicking the user

This is distinct from offline-capable (which just means the app sometimes works offline) and from a plain PWA (which adds installability and a manifest but may not handle offline at all).


The Building Blocks

Service Workers

A service worker is a JavaScript file that runs in a background thread, separate from your main page. It acts as a programmable network proxy — intercepting every fetch request your app makes and deciding how to respond.

Browser Tab  ──fetch──▶  Service Worker  ──(cache hit?)──▶  Cache Storage
                                        │
                                        └──(miss)──▶  Network  ──▶  Cache Storage

Key facts:

  • Runs in its own thread (no DOM access)
  • Persists across page loads and browser restarts
  • Only works on https:// (or localhost)
  • Has a lifecycle: install → activate → fetch

Registering a Service Worker

// main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });
      console.log('SW registered:', registration.scope);
    } catch (err) {
      console.error('SW registration failed:', err);
    }
  });
}

Why wait for load? Registering after load ensures the SW doesn't compete with critical page resources during startup.

The Service Worker Lifecycle

// sw.js
const CACHE_NAME = 'app-shell-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/app.js',
  '/styles.css',
  '/icons/logo-192.png',
];

// 1. Install — pre-cache your app shell
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
  );
  self.skipWaiting(); // Activate immediately without waiting
});

// 2. Activate — clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key))
      )
    )
  );
  self.clients.claim(); // Take control of open tabs immediately
});

Caching Strategies

Choosing the right caching strategy is the most important architectural decision in an offline-first app. There's no single winner — different resources call for different approaches.

1. Cache First (aka Cache Falling Back to Network)

Best for: Static assets — fonts, icons, CSS, JS bundles. These rarely change, and speed matters.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached ?? fetch(event.request);
    })
  );
});

✅ Lightning fast
⚠️ Won't pick up updates until cache is invalidated


2. Network First (aka Network Falling Back to Cache)

Best for: API responses, dynamic content — fresh data is preferred, but stale is better than nothing.

self.addEventListener('fetch', (event) => {
  if (!event.request.url.includes('/api/')) return;

  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const clone = response.clone();
        caches.open('api-cache-v1').then((cache) => cache.put(event.request, clone));
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

✅ Always fresh when online
⚠️ Slow on flaky networks — you wait for the timeout before serving cache


3. Stale While Revalidate

Best for: Frequently updated but non-critical content — news feeds, dashboards, profile data. Serve cache instantly, update in the background.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('dynamic-v1').then((cache) => {
      return cache.match(event.request).then((cached) => {
        const networkFetch = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });
        return cached ?? networkFetch;
      });
    })
  );
});

✅ Instant load + eventual freshness
⚠️ User might see stale data momentarily — communicate this in your UI


4. Cache Only

Best for: Pre-cached app shell during offline. No network attempt at all.

event.respondWith(caches.match(event.request));

5. Network Only

Best for: Analytics, non-idempotent POSTs that must reach the server. Don't cache these.

event.respondWith(fetch(event.request));

Strategy Decision Matrix

Resource TypeRecommended Strategy
HTML shell / entryCache First
JS/CSS bundlesCache First
API: read-heavyStale While Revalidate
API: write/mutationNetwork Only + Queue
Images (non-critical)Cache First
User-generated contentNetwork First
Analytics/loggingNetwork Only

Using Workbox: Don't Reinvent the Wheel

Writing service workers from scratch is error-prone. Workbox (by Google) provides battle-tested abstractions for every strategy above.

npm install workbox-webpack-plugin --save-dev
# or for Vite:
npm install vite-plugin-pwa --save-dev

Workbox in a Vite + React Project

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.yourapp\.com\/v1\//,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 50,
                maxAgeSeconds: 60 * 60 * 24, // 24 hours
              },
              networkTimeoutSeconds: 10,
            },
          },
          {
            urlPattern: /^https:\/\/fonts\.googleapis\.com\//,
            handler: 'StaleWhileRevalidate',
            options: {
              cacheName: 'google-fonts',
            },
          },
        ],
      },
      manifest: {
        name: 'My Offline App',
        short_name: 'OfflineApp',
        theme_color: '#1a1a2e',
        icons: [
          { src: 'icons/192.png', sizes: '192x192', type: 'image/png' },
          { src: 'icons/512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
    }),
  ],
});

This single config gives you precaching of all static assets, runtime caching for your API with NetworkFirst, font caching with StaleWhileRevalidate, and auto-update behavior when new SW versions ship.


Background Sync: Surviving Write Operations Offline

Reads are easy. Writes are where offline-first gets hard. What happens when a user submits a form while offline?

Background Sync queues failed requests and replays them automatically when connectivity returns.

Queuing a Failed POST

// In your app code
async function submitForm(data) {
  try {
    const res = await fetch('/api/submissions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error('Server error');
    return await res.json();
  } catch (err) {
    // Store in IndexedDB for later sync
    await queueForSync('form-submissions', data);
    showToast('Saved offline — will sync when connected');
  }
}

The Service Worker Sync Handler

// sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'form-submissions') {
    event.waitUntil(replayQueuedSubmissions());
  }
});

async function replayQueuedSubmissions() {
  const db = await openDB('sync-queue', 1);
  const all = await db.getAll('form-submissions');

  for (const item of all) {
    try {
      const res = await fetch('/api/submissions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item.data),
      });
      if (res.ok) {
        await db.delete('form-submissions', item.id);
      }
    } catch {
      // Will retry on next sync event
    }
  }
}

Registering the Sync

// Trigger from your app after queueing
async function queueForSync(tag, data) {
  const db = await openDB('sync-queue', 1, {
    upgrade(db) { db.createObjectStore(tag, { autoIncrement: true, keyPath: 'id' }); },
  });
  await db.add(tag, { data, timestamp: Date.now() });

  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register(tag);
}

Use idb (npm install idb) for a promise-based IndexedDB wrapper — it's small and ergonomic.


IndexedDB as Your Offline Database

For complex offline data (not just form queues), you need a real client-side database. IndexedDB is the browser-native solution — it supports gigabytes of structured data, indexes, and transactions.

Recommended: Use Dexie.js

Dexie.js is the cleanest IndexedDB abstraction available in 2026:

npm install dexie
// db.js
import Dexie from 'dexie';

export const db = new Dexie('MyAppDatabase');

db.version(1).stores({
  tasks:    '++id, title, status, updatedAt, synced',
  projects: '++id, name, ownerId',
});

// Create
await db.tasks.add({ title: 'Fix login bug', status: 'todo', synced: false });

// Read with index
const pending = await db.tasks.where('synced').equals(0).toArray();

// Update
await db.tasks.update(taskId, { status: 'done', updatedAt: Date.now() });

// Bulk operations
await db.tasks.bulkPut(serverTasks.map((t) => ({ ...t, synced: true })));

Sync Pattern: Optimistic Updates + Server Reconciliation

async function completeTask(taskId) {
  // 1. Optimistic local update
  await db.tasks.update(taskId, {
    status: 'done',
    updatedAt: Date.now(),
    synced: false,
  });
  updateUI(); // Re-render from local DB immediately

  // 2. Attempt server sync
  try {
    await fetch(`/api/tasks/${taskId}`, {
      method: 'PATCH',
      body: JSON.stringify({ status: 'done' }),
    });
    await db.tasks.update(taskId, { synced: true });
  } catch {
    // Remains unsynced — Background Sync will retry
  }
}

UX for Unreliable Networks

Technical resilience is only half the battle. Your UX must communicate the network state clearly — without alarming users or overwhelming them with banners.

1. Detect and Communicate Connectivity

// useNetworkStatus.js (React hook)
import { useState, useEffect } from 'react';

export function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  const [wasOffline, setWasOffline] = useState(false);

  useEffect(() => {
    const handleOnline = () => {
      setIsOnline(true);
      if (wasOffline) {
        showToast('Back online — syncing your changes…');
        setWasOffline(false);
      }
    };
    const handleOffline = () => {
      setIsOnline(false);
      setWasOffline(true);
      showToast('You're offline — changes will sync when reconnected', { persistent: true });
    };

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, [wasOffline]);

  return isOnline;
}

2. Offline Indicator Component

// OfflineBanner.jsx
export function OfflineBanner({ isOnline }) {
  if (isOnline) return null;

  return (
    <div
      role="status"
      aria-live="polite"
      className="offline-banner"
    >
      <span aria-hidden>📡</span> You're offline — your work is saved locally
    </div>
  );
}
.offline-banner {
  position: fixed;
  bottom: 1rem;
  left: 50%;
  transform: translateX(-50%);
  background: #1c1c1e;
  color: #f5f5f7;
  padding: 0.6rem 1.2rem;
  border-radius: 2rem;
  font-size: 0.875rem;
  box-shadow: 0 4px 20px rgba(0,0,0,0.3);
  z-index: 9999;
}

3. Pending Sync Indicators

Show users what's waiting to sync so they don't feel their data is lost:

function SyncStatusBadge({ unsynced }) {
  if (unsynced === 0) return null;
  return (
    <span className="sync-badge" title={`${unsynced} change(s) pending sync`}>
      ⏳ {unsynced} pending
    </span>
  );
}

4. Skeleton Screens Over Spinners

When loading from cache, skeleton screens feel faster and less uncertain than spinners:

function TaskCard({ task }) {
  if (!task) {
    return (
      <div className="card skeleton">
        <div className="skeleton-line w-3/4" />
        <div className="skeleton-line w-1/2" />
      </div>
    );
  }
  return <div className="card">{task.title}</div>;
}

PWA Manifest and Installability

An offline-first app should also be installable. The Web App Manifest makes your PWA installable on Android, iOS, and desktop.

// public/manifest.json
{
  "name": "Field Operations App",
  "short_name": "FieldOps",
  "description": "Manage field tasks even without internet",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#0f172a",
  "theme_color": "#6366f1",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x800",
      "type": "image/png",
      "form_factor": "wide"
    }
  ]
}

Link it in your HTML:

<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#6366f1" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

Testing Your Offline PWA

Chrome DevTools

  1. Open DevTools → Application tab
  2. Service Workers panel: Test update flow, force update, unregister
  3. Cache Storage: Inspect exactly what's cached
  4. Network tab → Throttling: Simulate "Offline", "Slow 3G", "Fast 3G"

Lighthouse Audit

Run a PWA audit: DevTools → Lighthouse → select "Progressive Web App" → Generate report.

Target scores:

  • ✅ Registers a service worker
  • ✅ Responds with 200 when offline
  • ✅ Web app manifest meets installability requirements
  • ✅ Icons are present and sized correctly

Playwright Offline Tests

// tests/offline.spec.js
import { test, expect } from '@playwright/test';

test('app loads from cache when offline', async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();

  // Load once online to populate cache
  await page.goto('http://localhost:5173');
  await page.waitForLoadState('networkidle');

  // Go offline
  await context.setOffline(true);

  // Reload — should still work
  await page.reload();
  await expect(page.locator('h1')).toBeVisible();
  await expect(page.locator('.offline-banner')).toBeVisible();
});

Best Practices

  • Version your caches — include a version string in cache names (app-shell-v3) and delete old caches on activate
  • Set cache expiration — use Workbox's ExpirationPlugin to evict stale entries automatically
  • Don't cache everything — auth tokens, payment flows, and admin mutations should always be network-only
  • Handle partial failures — a cached page with a failed API call needs graceful empty states, not a broken layout
  • Use skipWaiting carefully — it activates new SWs immediately but can cause inconsistency if the tab has already loaded assets from the old SW
  • Pre-cache selectively — precaching 50MB of assets is worse than precaching nothing; be ruthless about what's in your app shell
  • Test on real devices — emulators don't reproduce the true latency, battery constraints, and network switching behavior of real mobile hardware
  • Add beforeinstallprompt handling — control when and how the install prompt appears; don't let the browser show it at a bad moment

Common Mistakes

❌ Caching API responses that contain auth tokens

Never cache Authorization headers in your responses. These expire. Store data without credentials.

❌ Forgetting to handle the fetch event for navigation requests

If you don't intercept navigation fetches, the HTML shell won't load offline. Always add a fallback for event.request.mode === 'navigate':

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match('/index.html'))
    );
    return;
  }
  // ... rest of your logic
});

❌ Blocking the install event with slow operations

event.waitUntil keeps the SW in the installing state until the promise resolves. If it times out, the SW fails to install silently. Keep pre-caching fast; defer large assets.

❌ Ignoring iOS Safari limitations

iOS Safari (as of 2026) still has quirks: SW storage quota is limited (~50MB per origin), and push notifications require specific entitlements. Test thoroughly on Safari, not just Chrome.

❌ Not providing offline fallback pages

For pages that can't be cached (user-specific dashboards), provide a meaningful offline fallback — not a browser error. Add an /offline.html to your pre-cache and serve it as the navigation fallback.

❌ Treating navigator.onLine as ground truth

navigator.onLine can return true on a captive portal with no real internet. Combine it with actual fetch probes to detect functional connectivity:

async function isReallyOnline() {
  try {
    await fetch('/api/ping', { method: 'HEAD', cache: 'no-store' });
    return true;
  } catch {
    return false;
  }
}

🚀 Pro Tips

  • Use workbox-window in your app code to detect SW updates and prompt users to reload: wb.addEventListener('waiting', showUpdateBanner)
  • Broadcast Channel API lets your SW communicate status updates (sync complete, new content available) directly to open tabs without polling
  • Periodic Background Sync (Chrome/Android) can pre-fetch fresh data on a schedule — great for news apps and dashboards that need to feel instant on open
  • Range requests matter for media — if your app caches audio or video, handle Range headers in your fetch handler or media won't seek correctly
  • Use cache.put over cache.add when you already have a Response object — it avoids a redundant network fetch
  • Monitor cache storage quota with navigator.storage.estimate() and warn users before you hit the limit, rather than silently failing writes
  • Add a "Clear offline data" option in your app's settings — power users will thank you, and it's essential for debugging

📌 Key Takeaways

  • Offline-first means local-first: treat your IndexedDB as the source of truth and sync to the server, not the other way around
  • Service workers are proxies: they intercept fetches — use this power to serve cached responses, queue mutations, and update caches in the background
  • Pick the right strategy per resource: static assets → Cache First; API reads → Stale While Revalidate; API writes → queue + Background Sync
  • Workbox and vite-plugin-pwa handle the boilerplate; write strategy config, not raw fetch handlers, for production apps
  • Background Sync is essential for mutations: queue writes in IndexedDB, replay on sync events, and always confirm to users that their data is safe
  • UX is half the battle: offline banners, skeleton screens, sync status badges, and restore toasts make the difference between "broken" and "resilient"
  • Test offline paths explicitly — use DevTools throttling and Playwright offline context; don't discover these bugs in production
  • iOS Safari still requires care: quota limits, lack of push without entitlements, and SW quirks mean you need a Safari-specific test pass

Conclusion

Offline-first isn't just an optimization — it's a fundamental shift in how you architect trust with your users. Networks are unreliable. Devices go underground, into tunnels, across oceans. Users shouldn't have to think about that.

By combining service workers, smart caching, IndexedDB, Background Sync, and honest UX, you can build web apps that feel as dependable as native — without the app store, the install friction, or the platform tax.

Start small: add a service worker that pre-caches your app shell and serves it offline. Then layer in API caching, then write queuing, then richer sync logic. You don't have to boil the ocean on day one. But every step you take toward offline-first is a step toward an app that your users can depend on — no matter where they are or what their signal bar says.


Further reading: Google's Offline Cookbook, Workbox Documentation, Jake Archibald's Service Worker spec explainer, Dexie.js docs

All Articles
PWAService WorkersOffline-FirstWeb PerformanceCachingProgressive Web AppsFrontendMobile Web

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.