PWAs 2.0: Offline-First

The Pillars of Offline-First

Building a true offline-first PWA requires rethinking every layer of the stack. These pillars form the structural backbone:

🗄️

Local-First Storage

IndexedDB as the primary data store. The cloud is a backup, not the origin. Reads never block on network.

⚙️

Service Workers

An in-browser proxy that intercepts network requests and serves cached responses intelligently.

🔄

Background Sync

Queue mutations locally. Flush them to the server automatically when connectivity is restored.

⚔️

Conflict Resolution

A strategy — CRDTs, last-write-wins, or user arbitration — for merging divergent states.

📡

Sync Engine

A two-way sync protocol that knows what each client has and delivers exactly the delta needed.

🔔

Push Notifications

Deliver timely updates even when the app isn't open, keeping offline data eventually consistent.

Service Workers, Reimagined

Service workers are the engine of offline-first PWAs. They sit between your app and the network — a programmable proxy that you control. In PWAs 2.0, their role expands dramatically beyond simple caching.

// Request Interception Flow
App
fetch()
Service Worker
intercept
Strategy
cache?
Response
instant ✓
Cache-first strategy: network only called on miss or revalidation

The key innovation in PWAs 2.0 is layered caching strategies. Different resources need different freshness guarantees:

service-worker.js
// Workbox-powered routing with layered strategies
import { registerRoute, Route } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate, NetworkFirst }
  from 'workbox-strategies';

// App shell: cache-first — never changes
registerRoute(
  ({ request }) => request.destination === 'document',
  new CacheFirst({ cacheName: 'shell-v2' })
);

// API data: stale-while-revalidate — fast + fresh
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/feed'),
  new StaleWhileRevalidate({
    cacheName: 'api-feed',
    plugins: [new ExpirationPlugin({ maxAgeSeconds: 3600 })]
  })
);

// User data: network-first — consistency critical
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/user'),
  new NetworkFirst({
    networkTimeoutSeconds: 3,
    cacheName: 'user-data'
  })
);

Notice the deliberate mismatch: the app shell uses cache-first (the HTML never needs to be fresh), feed data uses stale-while-revalidate (show something immediately, update in the background), and user-specific data uses network-first with a fallback (correctness matters more than speed for personal data).

Building a Sync Engine

The hardest problem in offline-first development isn't going offline — it's coming back online. When a user has been working offline, their local state has diverged from the server. Merging these divergent histories correctly is the core engineering challenge.

⚠ The Conflict Trap
Last-write-wins is tempting because it's simple. But when two users edit the same document on opposite sides of a 30-minute network split, "last write wins" means one of them loses their work silently. Design your conflict strategy before you design anything else.

CRDTs: Conflict-Free by Design

Conflict-free Replicated Data Types (CRDTs) are mathematical structures that can be merged deterministically regardless of operation order. Instead of storing "the user's name is Alice", you store a sequence of operations: "user set name to Alice at T1", "user set name to Bob at T2". Any replica can replay these operations and arrive at the same result.

sync-engine.ts
interface Operation {
  id: string;       // UUID
  type: string;     // 'SET' | 'DELETE' | 'APPEND'
  path: string;     // dot-notation field path
  value: unknown;   // payload
  timestamp: number; // HLC (hybrid logical clock)
  clientId: string;  // for tie-breaking
}

class SyncEngine {
  private pending: Operation[] = [];

  // Queue an operation locally, apply optimistically
  async applyLocal(op: Operation) {
    await db.apply(op);           // write to IndexedDB
    this.pending.push(op);        // enqueue for server
    scheduleSync();               // attempt flush
  }

  // Flush pending ops to server when online
  async flush() {
    if (!navigator.onLine) return;
    const batch = [...this.pending];
    const serverOps = await api.sync(batch);
    await this.mergeServerOps(serverOps); // CRDT merge
    this.pending = this.pending.filter(
      op => !batch.find(b => b.id === op.id)
    );
  }
}

The key detail is the hybrid logical clock (HLC). Wall-clock time is unreliable across devices — users can have incorrect system clocks. HLCs combine a physical timestamp with a monotonic counter, giving you causally ordered events that survive clock skew.

IndexedDB as the Source of Truth

For most teams, IndexedDB's raw API is too verbose to use directly. Libraries like Dexie.js, RxDB, and Electric SQL wrap it in ergonomic abstractions while preserving its transactional guarantees.

db.ts — Dexie.js schema
import Dexie, { Table } from 'dexie';

export class AppDatabase extends Dexie {
  notes!: Table<Note>;
  operations!: Table<Operation>;

  constructor() {
    super('myapp');
    this.version(3).stores({
      // Index on synced flag for efficient pending-ops queries
      notes:      'id, updatedAt, [userId+updatedAt]',
      operations: 'id, synced, timestamp, [synced+timestamp]'
    });
  }
}

const db = new AppDatabase();

// Live query — React hook that re-renders on data change
export function useNotes(userId: string) {
  return useLiveQuery(
    () => db.notes
      .where('userId').equals(userId)
      .sortBy('updatedAt'),
    [userId]
  );
}

The useLiveQuery hook is the architectural linchpin: your UI subscribes to local data directly. When a sync operation writes new data to IndexedDB, every subscribed component re-renders automatically — no Redux actions, no manual cache invalidation.

Storage Quota Realities

Browsers allocate storage quota dynamically. Chrome grants up to 60% of available disk space; Safari is more conservative and will evict caches under storage pressure. Always use the Storage API to request persistent storage for data your users cannot afford to lose:

storage-permission.ts
// Request persistent storage on first meaningful interaction
async function requestPersistence() {
  if (navigator.storage?.persist) {
    const granted = await navigator.storage.persist();
    if (!granted) {
      // Fallback: warn user, prioritize critical data
      pruneLowPriorityCache();
    }
  }
}

// Check quota before large writes
const { usage, quota } = await navigator.storage.estimate();
const usagePercent = (usage / quota) * 100;
if (usagePercent > 80) evictOldData();

Choosing the Right Strategy

There's no one-size-fits-all caching strategy. The right choice depends on how frequently data changes and the cost of serving stale data.

Strategy Best For Tradeoff
Cache First App shell, fonts, static assets Stale until version bump. Fast every time.
Network First User profile, payments, auth Slow offline. Accurate when online.
Stale-While-Revalidate News feed, product catalog Always fast. One-render stale. Best UX.
Network Only Analytics, tracking pixels Drops silently offline. Intentional.
Cache Only Pre-cached game assets Never updates. Deterministic.

The most impactful strategy for most apps is Stale-While-Revalidate for API responses. Users see data instantly from cache; in the background, the service worker fetches an update and quietly refreshes the UI. This makes your app feel both fast and current — without waiting for the network.

Background Sync & Retry

The Background Sync API solves a subtle but critical UX problem: what happens when the user submits a form, the network fails, and they close the tab? Without background sync, that data is lost. With it, the browser registers a sync event and retries the request automatically — even after the tab is closed — as soon as connectivity is restored.

background-sync.js
// In your app: register a sync tag when offline
async function saveComment(comment) {
  await db.pendingComments.add(comment);  // persist locally
  const reg = await navigator.serviceWorker.ready;
  await reg.sync.register('sync-comments');
}

// In service-worker.js: handle the sync event
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-comments') {
    event.waitUntil(
      db.pendingComments
        .toArray()
        .then(comments =>
          Promise.all(comments.map(async c => {
            await api.postComment(c);
            await db.pendingComments.delete(c.id);
          }))
        )
    );
  }
});

The browser handles exponential backoff automatically. Your service worker only needs to define what "success" looks like. If the promise rejects, the browser will retry the sync — up to implementation-specific limits.

Chrome ✓
Edge ✓
Firefox ◐
Safari ✗

Safari lacks Background Sync support. For cross-browser resilience, combine with a manual online-event listener as fallback.

The New Offline-First Stack

The tooling ecosystem around offline-first PWAs has matured dramatically. Teams no longer need to build sync engines from scratch.

🔵

Electric SQL

Postgres to local SQLite sync with automatic CRDTs. Your backend is your sync engine.

📦

Replicache

Offline-first sync framework with optimistic mutations, conflict resolution, and push/pull protocol.

🟠

TinyBase

Reactive local-first database with built-in CRDT support and multiple persistence adapters.

Vite PWA Plugin

Zero-config service worker generation with Workbox integration. Precaching in one line.

🗃️

Dexie.js

The most ergonomic IndexedDB wrapper. Live queries, type safety, and migration support.

🔶

Legend-State

Fine-grained reactive state with first-class offline sync and automatic persistence.