React PWA Offline Caching Strategies: From App Shell to Runtime Mastery
A practical guide to React PWA offline caching: precache app shells, runtime strategies, updates, fallbacks, and pitfalls with Workbox examples.
Image used for representation purposes only.
Overview
Progressive Web Apps (PWAs) turn your React app into an installable, resilient experience that keeps working when the network doesn’t. The cornerstone is offline caching: choosing what to cache, when to serve it, and how to update it without breaking users. This guide covers practical strategies, code patterns, and pitfalls so you can ship a fast, reliable React PWA.
The App Shell Model for React
Most React PWAs follow the app shell pattern:
- App shell: static assets needed to load the UI (HTML, CSS, JS bundles, fonts, icons).
- Content: dynamic data and media fetched at runtime.
Cache the shell aggressively (precaching), then layer runtime strategies for APIs and media. This produces instant loads even offline, while keeping data fresh when the network is available.
Precaching vs Runtime Caching
- Precaching: Bundled at build time; URLs and revision hashes are embedded into the service worker (SW). Ideal for the app shell and other versioned static assets. Guarantees availability offline.
- Runtime caching: Decides on-the-fly what to cache as users navigate. Perfect for APIs, images, and other non-deterministic or frequently updated resources.
Workbox (by Google Chrome team) remains the most robust toolkit to implement both with minimal boilerplate.
Core Strategies (When to Use Each)
- Cache First: Serve from cache if present; fetch and update cache otherwise. Best for versioned static assets and immutable images. Risk: stale content if not versioned.
- Network First: Try network; fall back to cache if offline. Best for HTML navigation requests and API responses that must be reasonably fresh. Risk: slower on flaky networks.
- Stale-While-Revalidate: Serve from cache immediately; update cache in background. Great for avatars, article lists, or JSON that can be briefly stale. Risk: users may see outdated data until refresh.
- Network Only / Cache Only: Specialized cases; e.g., bypass cache for authenticated or sensitive requests, or force cache for offline fallbacks.
Project Setup Basics (React + Workbox)
Whether you use Create React App, Vite, or a custom Webpack build, you’ll need a production service worker. Two Workbox integration modes:
- generateSW: Workbox generates a complete SW for you; zero-configuration precache of build outputs.
- injectManifest: You write a custom SW and Workbox injects the precache manifest; more control for advanced routing and plugins.
Example Workbox configuration (generateSW):
// workbox.config.js
module.exports = {
globDirectory: 'dist',
globPatterns: [
'**/*.{html,js,css,svg,png,webp,woff2}'
],
swDest: 'dist/sw.js',
clientsClaim: true,
skipWaiting: false, // prefer controlled updates (see Update Flow section)
navigateFallback: '/index.html',
runtimeCaching: [
{
urlPattern: ({request}) => request.mode === 'navigate',
handler: 'NetworkFirst',
options: {
cacheName: 'pages',
networkTimeoutSeconds: 3,
}
},
{
urlPattern: /\/api\/.*\/*.json/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-json',
plugins: [
new workbox.expiration.ExpirationPlugin({maxAgeSeconds: 60 * 5})
]
}
},
{
urlPattern: /\.(png|jpg|jpeg|webp|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images',
plugins: [
new workbox.expiration.ExpirationPlugin({maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30}),
new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]})
]
}
}
]
};
Service worker registration (React entry):
// src/main.tsx or src/index.tsx
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
Navigation Requests and the HTML Fallback
For single-page React apps, treat navigation requests specially:
- NetworkFirst on navigations yields fresher HTML, but still falls back to cached HTML when offline.
- Use navigateFallback to serve index.html when the network fails, keeping client-side routing functional.
- Provide an offline page or inline UI state for truly offline-only routes.
Example offline fallback route:
workbox.routing.registerRoute(
({request}) => request.mode === 'navigate',
new workbox.strategies.NetworkFirst({
cacheName: 'pages',
plugins: [new workbox.expiration.ExpirationPlugin({maxEntries: 50})]
})
);
API Caching Patterns
- Non-authenticated, read-only JSON: Stale-While-Revalidate with short maxAge (1–5 minutes). Users get instant responses with quiet background refreshes.
- Authenticated or sensitive routes: NetworkOnly or selectively cache after stripping PII. Consider server headers: Cache-Control: no-store for sensitive endpoints.
- Pagination/infinite lists: Use Stale-While-Revalidate and per-page keys; clear on logout or account switch.
- Mutations (POST/PUT/DELETE): Don’t cache responses automatically. Use Background Sync for retriable POSTs when offline.
Background Sync example with Workbox:
import {Queue} from 'workbox-background-sync';
const queue = new Queue('post-queue');
self.addEventListener('fetch', (event) => {
const {request} = event;
if (request.method === 'POST' && new URL(request.url).pathname.startsWith('/api/submit')) {
event.respondWith((async () => {
try {
return await fetch(request.clone());
} catch (err) {
await queue.pushRequest({request});
return new Response(JSON.stringify({queued: true}), {status: 202});
}
})());
}
});
Images, Fonts, and Media
- Images: CacheFirst with expiration and CacheableResponse (cache opaque CDN responses). Consider responsive formats (WebP/AVIF) and lazy loading to reduce cache footprint.
- Fonts: CacheFirst and long-lived. Self-host with immutable filenames to avoid stale versions.
- Large media: Prefer streaming and Range requests; often better to let the browser HTTP cache handle it than SW caching.
IndexedDB for Structured Offline Data
The Cache Storage API is great for request/response pairs but awkward for relational data. Use IndexedDB for:
- Complex JSON (notes, drafts, cart items) you need to query or merge.
- Conflict resolution on re-sync.
Pattern:
- Write data to IndexedDB immediately (optimistic UI), then sync in background when network returns.
- Keep a small SW-managed cache for list endpoints; hydrate React from IndexedDB on app load.
Example (using idb):
import {openDB} from 'idb';
export const db = await openDB('app', 1, {
upgrade(db) {
db.createObjectStore('drafts', {keyPath: 'id'});
}
});
export async function saveDraft(draft) {
await db.put('drafts', draft);
}
Versioning and Invalidation
- Use hashed filenames for static assets (main.abc123.js). Precache entries include revisions; updates purge old cache entries automatically.
- Name runtime caches with versions (images-v3). On SW activate, delete legacy caches.
Example cleanup:
self.addEventListener('activate', (event) => {
const keep = new Set(['pages', 'api-json', 'images']);
event.waitUntil(
caches.keys().then(keys => Promise.all(keys.filter(k => !keep.has(k)).map(k => caches.delete(k))))
);
});
Update Flow UX: Don’t Blindly skipWaiting()
Service workers update in phases: installing -> waiting -> activated. Calling skipWaiting immediately can swap code while users are mid-session, causing UI/runtime mismatches. Prefer a controlled update prompt.
With workbox-window in React:
import {Workbox} from 'workbox-window';
if ('serviceWorker' in navigator) {
const wb = new Workbox('/sw.js');
wb.addEventListener('waiting', () => {
// Show toast/banner: “Update available”
// If user accepts:
wb.messageSW({type: 'SKIP_WAITING'});
});
wb.addEventListener('controlling', () => {
window.location.reload();
});
wb.register();
}
Navigation Preload for Faster First Paint
Enable navigation preload so the browser starts fetching HTML while the SW is booting, cutting cold-start latency.
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
})());
});
In your route handler, prefer the preloaded response if available.
Integrating with HTTP Caching
- Immutable assets: Cache-Control: public, max-age=31536000, immutable with content-hashed names. Both HTTP cache and SW benefit.
- Sensitive data: no-store on server to prevent browser or SW caching. Respect Vary and CORS headers.
- Remember: SW cache is separate from the HTTP cache; you manage both intentionally.
Offline Fallbacks That Delight
- Navigation: offline.html or your React shell with a clear “You’re offline” state.
- Media: placeholder.svg for images; low-res blurred placeholders work well.
- Data: last-known-good JSON for critical views; show timestamp of cached data.
Example generic fallbacks with Workbox:
const FALLBACK_HTML_URL = '/offline.html';
const FALLBACK_IMG_URL = '/images/fallback.png';
workbox.routing.setCatchHandler(async ({event}) => {
switch (event.request.destination) {
case 'document':
return caches.match(FALLBACK_HTML_URL);
case 'image':
return caches.match(FALLBACK_IMG_URL);
default:
return Response.error();
}
});
Security and Privacy Considerations
- Don’t cache tokens, PII, or user-specific HTML. Segment caches per auth state and clear on logout.
- Beware opaque responses (status 0) from cross-origin CDNs; use CacheableResponsePlugin and CORS-safe endpoints.
- Respect robots and license restrictions for third-party assets; avoid caching content you can’t persist.
Testing and Observability
- Chrome DevTools > Application > Service Workers: simulate offline, clear storage, inspect caches, check update flow.
- Lighthouse PWA audits: checks installability, offline readiness, and best practices.
- Real user monitoring: track cache hit rate, offline success screens, and background sync retries.
- Integrate E2E tests that toggle offline mode and assert fallbacks and cache behavior.
Framework Notes (CRA, Vite, Next.js)
- Create React App: v4+ previously shipped with an optional SW via cra-template-pwa. If you’ve ejected or moved on, prefer Workbox directly or a Vite plugin.
- Vite: use vite-plugin-pwa, which wraps Workbox generateSW/injectManifest with DX-friendly defaults.
- Next.js: next-pwa adds Workbox-based caching to production builds; tailor runtimeCaching to API and image domains.
Common Pitfalls
- Using CacheFirst for HTML: can lock users to old shells; prefer NetworkFirst for navigations.
- skipWaiting without a UX: may crash SPA during updates.
- Caching POST responses: can leak sensitive data or create inconsistency.
- Huge runtime caches: set maxEntries and maxAge; consider per-origin quotas and storage pressure.
- Ignoring logout flows: clear user caches and IndexedDB on sign-out.
A Pragmatic Default Strategy
- Precaching: all hashed static assets from your build.
- HTML navigations: NetworkFirst + navigation preload + offline fallback.
- Public JSON APIs: Stale-While-Revalidate with 1–5 minute maxAge.
- Images and fonts: CacheFirst with expiration and cacheable response.
- Mutations: Background Sync queues; optimistic UI with IndexedDB.
- Updates: user-driven refresh via workbox-window (no forced skipWaiting).
Deployment Checklist
- Service worker served at the scope root (/sw.js) over HTTPS.
- Manifest.json with icons, theme/color, display=standalone, start_url.
- Content-hashed static assets and correct HTTP caching headers.
- Lighthouse PWA score green; offline audit passes.
- Runtime caches capped with ExpirationPlugin; sensitive routes excluded.
- Update prompt implemented; caches cleaned on activate.
- Offline fallbacks for document/image/data paths.
Conclusion
Offline resilience is not just a checkbox—it’s a set of deliberate trade-offs. Start with a solid app shell via precaching, layer targeted runtime strategies, and design a humane update flow. With Workbox and a few focused patterns, your React PWA will load instantly, survive spotty networks, and keep users productive anywhere.
Related Posts
Build a React Progressive Web App (PWA) with Vite and Workbox: A Complete Guide
Build a fast, installable React Progressive Web App with Vite and Workbox: manifest, caching, offline, updates, and Lighthouse audits.
Apollo vs Relay: Choosing the Right GraphQL Client for React
Apollo vs Relay: strengths, trade‑offs, and when to choose each for React GraphQL apps, from caching and pagination to SSR, typing, and developer UX.
React Optimistic UI Updates: A Practical, Fail‑Safe Tutorial
Build fast, resilient optimistic UI updates in React. Learn patterns, rollback, and examples using plain hooks and TanStack Query.