EmpireUI
Get Pro
← Blog8 min read#react#pwa#indexeddb

Offline-First React Apps: IndexedDB, SyncManager, PWA

Build React apps that work without internet using IndexedDB, the Background Sync API, and PWA service workers — no more blank screens when users go offline.

Developer laptop with network monitoring tools and offline mode indicator on screen

Why Offline-First Isn't Optional Anymore

Honestly, most React apps fall apart the moment a user loses their connection. You see it everywhere — a blank white screen, a spinner that never stops, or an unrecoverable error boundary. That's not acceptable when you're building something people depend on.

Offline-first isn't a luxury feature for fancy apps. It's the baseline expectation for anyone building tools used on mobile, in spotty café WiFi, or by international teams with unpredictable infrastructure. Your app should degrade gracefully, not crash entirely.

The browser already gives you everything you need: IndexedDB for local storage, Service Workers for network interception, the Background Sync API for queuing failed requests, and the Cache API for assets. You're just not using them yet.

This article walks through the actual implementation — not the theory. We'll build a React app that stores data locally first, syncs when connectivity returns, and registers as a proper PWA that installs on the user's device.

IndexedDB in React: Ditch localStorage, Use Dexie.js

localStorage is synchronous and capped at about 5MB. That's fine for a theme toggle preference. It's not fine for user-generated content, form state, or any real dataset. IndexedDB is asynchronous, schema-based, and can store gigabytes — but the raw API is genuinely painful to work with.

Dexie.js v4.0.1 wraps IndexedDB in a clean Promise-based API. It handles schema migrations, typed queries, and has React hooks via dexie-react-hooks. Install both: npm install dexie dexie-react-hooks.

Here's a real setup — a simple task manager that persists locally:

// db.ts
import Dexie, { type EntityTable } from 'dexie';

export interface Task {
  id: number;
  title: string;
  completed: boolean;
  syncedAt: number | null; // null means pending sync
  updatedAt: number;
}

export class AppDatabase extends Dexie {
  tasks!: EntityTable<Task, 'id'>;

  constructor() {
    super('AppDatabase');
    this.version(1).stores({
      tasks: '++id, completed, syncedAt, updatedAt',
    });
  }
}

export const db = new AppDatabase();

The syncedAt field is the key pattern here. A null value means the record exists locally but hasn't been confirmed by the server. That's your pending sync queue. When syncedAt has a timestamp, the record is confirmed. This single field drives your entire offline strategy.

Service Worker Setup with Workbox 7

A Service Worker is a script that runs in a background thread, separate from your React app. It intercepts network requests, serves cached responses, and — critically — can run code even when your app isn't open in a tab.

Workbox 7.x is the standard way to configure one without writing 400 lines of manual caching logic. If you're on Vite, vite-plugin-pwa bundles Workbox for you. Add it: npm install -D vite-plugin-pwa.

// vite.config.ts
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\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              networkTimeoutSeconds: 4,
              expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 },
            },
          },
        ],
      },
      manifest: {
        name: 'Your React App',
        short_name: 'App',
        theme_color: '#0f172a',
        background_color: '#0f172a',
        display: 'standalone',
        icons: [
          { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
    }),
  ],
});

The NetworkFirst strategy with a 4-second timeout means: try the network, fall back to cache if it doesn't respond in time. For mutation endpoints (POST, PUT, DELETE), you don't cache responses — you queue the requests using Background Sync instead.

Background Sync API: Queuing Requests When Offline

Here's the thing: when a user creates a task while offline, you need to store it in IndexedDB immediately (optimistic update), then register a sync event so the browser sends it to your API whenever connectivity returns. The Background Sync API handles that second part.

The SyncManager lives on the Service Worker registration. You call navigator.serviceWorker.ready to get the active registration, then registration.sync.register('sync-tasks'). The browser fires a sync event on the Service Worker when it detects a connection — even if the user has already closed your app's tab.

// hooks/useTaskSync.ts
import { db } from '../db';

export async function addTaskOptimistic(title: string) {
  // 1. Write locally first — instant feedback, no waiting
  const id = await db.tasks.add({
    title,
    completed: false,
    syncedAt: null,
    updatedAt: Date.now(),
  });

  // 2. Register a background sync tag
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-tasks');
  } else {
    // Fallback: try syncing immediately
    await syncPendingTasks();
  }

  return id;
}

export async function syncPendingTasks() {
  const pending = await db.tasks
    .where('syncedAt')
    .equals(null)
    .toArray();

  for (const task of pending) {
    try {
      const res = await fetch('/api/tasks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: task.title, completed: task.completed }),
      });
      if (res.ok) {
        const { id: serverId } = await res.json();
        await db.tasks.update(task.id, { syncedAt: Date.now() });
      }
    } catch {
      // Still offline — SyncManager will retry
      break;
    }
  }
}

One thing most tutorials skip: the SyncManager isn't available in all browsers. Firefox doesn't support it as of late 2026. Always include the fallback path — syncPendingTasks() called on online events and on app mount covers the gaps. Check if you need toast notifications to communicate sync status to users in a non-intrusive way.

Handling Conflicts: Last-Write-Wins vs. Server Authority

What happens when a user edits a record offline, and someone else edits the same record on the server? You've got a conflict. How you resolve it depends entirely on your product, but you need to pick a strategy before you ship.

Last-write-wins (LWW) is the simplest approach: whichever write has the later updatedAt timestamp wins. It's what most apps use because it's easy to implement and good enough for most content types. The risk is accidental overwrites — User A loses their edit because User B synced 200ms later.

Server authority is safer: on sync, you always check if the server version has changed since you last fetched it (using an etag or version field). If it has, you surface a conflict UI and let the user decide. This is what Notion, Linear, and similar tools do. It's more code, but users don't lose data.

For the updatedAt approach to work across devices and timezones, don't use Date.now() directly on the client — use a monotonic sequence from the server, or at least warn users that their device clock matters. Clock skew is a real problem in offline-first systems.

React UI Patterns for Offline State

Detecting offline state in React is a two-line hook, but the UI patterns around it take thought. Don't block the entire app with an error banner — surface status without interrupting the user's flow.

// hooks/useNetworkStatus.ts
import { useState, useEffect } from 'react';

export function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  const [pendingCount, setPendingCount] = useState(0);

  useEffect(() => {
    const goOnline = () => setIsOnline(true);
    const goOffline = () => setIsOnline(false);

    window.addEventListener('online', goOnline);
    window.addEventListener('offline', goOffline);

    return () => {
      window.removeEventListener('online', goOnline);
      window.removeEventListener('offline', goOffline);
    };
  }, []);

  // Poll pending tasks count from IndexedDB
  useEffect(() => {
    const interval = setInterval(async () => {
      const { db } = await import('../db');
      const count = await db.tasks.where('syncedAt').equals(null).count();
      setPendingCount(count);
    }, 3000);
    return () => clearInterval(interval);
  }, []);

  return { isOnline, pendingCount };
}

Use this hook to render a small status chip in your app's header — something like 3 changes pending sync with an amber dot, or a green checkmark when everything's synced. A 32px chip in the top-right corner tells the user everything without interrupting them. Pair this with your theme toggle setup so the offline indicator respects dark mode naturally.

The performance profile of an offline-first app is also different — you're reading from IndexedDB on every render instead of fetching from an API. IndexedDB reads are fast (sub-millisecond for small datasets), but they're async. Always use Suspense boundaries or loading states when pulling data from Dexie hooks, and profile with the React DevTools profiler before assuming it's fast enough. Our React performance guide covers the full profiling workflow.

PWA Manifest and Install Prompt in React

A PWA that users actually install is fundamentally different from a website. It shows up in the OS app drawer, runs in standalone display mode (no browser chrome), and gets a slot in the battery-efficient background process queue for sync events.

The install prompt (BeforeInstallPromptEvent) fires when the browser decides your app is installable. You can't trigger it on demand — you can only hold a reference to the event and call .prompt() when a user explicitly asks to install.

// hooks/usePWAInstall.ts
import { useState, useEffect } from 'react';

type PromptEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: string }> };

export function usePWAInstall() {
  const [promptEvent, setPromptEvent] = useState<PromptEvent | null>(null);
  const [installed, setInstalled] = useState(false);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      setPromptEvent(e as PromptEvent);
    };
    window.addEventListener('beforeinstallprompt', handler);

    window.addEventListener('appinstalled', () => {
      setInstalled(true);
      setPromptEvent(null);
    });

    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const install = async () => {
    if (!promptEvent) return;
    await promptEvent.prompt();
    const { outcome } = await promptEvent.userChoice;
    if (outcome === 'accepted') setInstalled(true);
    setPromptEvent(null);
  };

  return { canInstall: !!promptEvent && !installed, install, installed };
}

Surface the install button only when canInstall is true. Don't show it on every page load — that's aggressive. A good place is after a user has completed a meaningful action (created their third task, finished onboarding). The browser also enforces a user engagement heuristic before firing beforeinstallprompt, so spamming it doesn't help anyway.

Testing Offline Behavior: Chrome DevTools and Vitest

Can you actually verify your offline logic works without pulling your WiFi cable? Yes. Chrome DevTools Network tab has an offline checkbox that throttles all network requests to nothing. Open your app, check the box, try adding data — does it save? Uncheck it — does it sync?

For automated tests, mock navigator.onLine and the fetch global with Vitest. Dexie ships with a Fake IndexedDB adapter (fake-indexeddb) that runs in Node.js without needing a real browser environment. Install it with npm install -D fake-indexeddb.

// tasks.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import 'fake-indexeddb/auto'; // Patches global.IDBFactory
import { db } from './db';
import { addTaskOptimistic, syncPendingTasks } from './hooks/useTaskSync';

beforeEach(async () => {
  await db.tasks.clear();
});

describe('offline task creation', () => {
  it('stores task locally with null syncedAt', async () => {
    await addTaskOptimistic('Write tests');
    const tasks = await db.tasks.toArray();
    expect(tasks).toHaveLength(1);
    expect(tasks[0].syncedAt).toBeNull();
  });

  it('updates syncedAt after successful sync', async () => {
    await addTaskOptimistic('Ship the feature');
    // Simulate network call resolving
    globalThis.fetch = async () => new Response(JSON.stringify({ id: 99 }), { status: 200 });
    await syncPendingTasks();
    const tasks = await db.tasks.toArray();
    expect(tasks[0].syncedAt).not.toBeNull();
  });
});

Write these tests before you wire up the real API. They run in milliseconds, they don't depend on a server, and they'll catch regressions the moment you refactor your sync logic. That's the only sensible way to build offline-first. Do it test-first, or you'll spend hours debugging race conditions in the browser.

FAQ

Does IndexedDB work in Safari for PWAs?

Yes, Safari has supported IndexedDB since version 10. The storage quota is more aggressive — Safari may evict data after 7 days if the user hasn't visited. Use the StorageManager Persistence API (navigator.storage.persist()) to request persistent storage and avoid eviction. Safari will prompt the user for permission.

What's the difference between Background Sync and Periodic Background Sync?

Background Sync fires once when connectivity returns after a failed request — great for queuing mutations. Periodic Background Sync fires on a schedule (minimum 12-hour intervals in Chrome) for pre-fetching content. Periodic requires the app to be installed as a PWA and the user to have granted permission. Most apps only need regular Background Sync.

Can I use React Query with IndexedDB for offline support?

Yes. React Query v5 has a persistQueryClient plugin that can use IndexedDB as a cache store via @tanstack/query-idb-persister. This gives you offline reads for free on any query. For offline writes, you'll still need to handle optimistic updates and Background Sync yourself — React Query doesn't queue failed mutations automatically.

How do I handle JWT token expiry for requests that were queued while offline?

This is a real problem. If a user queues a request offline and syncs 2 hours later, their access token may have expired. Store a refresh token separately, and in your Service Worker's sync handler, attempt a token refresh before replaying queued requests. If refresh fails, persist the queued item and show the user an auth error on next app open.

Does vite-plugin-pwa work with Next.js?

No — vite-plugin-pwa is Vite-only. For Next.js, use next-pwa (which wraps Workbox) or configure a custom Service Worker with next.config.js. The App Router in Next.js 15 also has its own caching layer that can conflict with Service Worker caching — test carefully and prefer NetworkFirst or StaleWhileRevalidate strategies over CacheFirst to avoid stale data issues.

How much data can I realistically store in IndexedDB?

On most browsers, IndexedDB can use up to 60% of available disk space. Chrome uses a shared quota across all storage APIs (IndexedDB, Cache API, Origin Private File System) — on a device with 64GB available, that's tens of gigabytes. In practice, you'll hit performance issues with single tables over 100k rows long before you hit storage limits. Index your query fields properly.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Push Notifications in React: Service Worker, Permission FlowProgressive Web Apps with React and Next.js in 2026IndexedDB in React: Offline Storage With Dexie.jsWorkbox Service Worker in React: Caching Strategies for PWA