
Build an Offline-First Web App with Workbox
A web app should not become a blank screen when a train enters a tunnel or a mobile connection drops. An offline-first architecture keeps the essential interface available, serves previously downloaded resources from a cache, and reconnects cleanly when the network returns. Service workers make this possible, while Workbox provides tested routing and caching tools that are easier to maintain than a large hand-written fetch handler.
This guide builds a practical offline layer for a Vite application. It separates build assets, page navigation, API data, and images because each resource needs a different freshness strategy.
Offline-first does not mean cache everything
A cache is a second source of data, so every cached resource needs an update rule. Static files with content hashes are safe to precache. HTML usually needs a network-first strategy so users see current deployments. Images can tolerate older responses, while account balances and private API data may not be appropriate for persistent caching at all.
Workbox implements common strategies such as Cache First, Network First, and Stale While Revalidate. The official runtime caching guide explains their tradeoffs. Choose a strategy per route instead of placing one global rule in front of every request.
Requirements and project setup
Service workers require HTTPS in production, although browsers allow them on localhost during development. Begin with an existing Vite project, then install the Workbox packages:
npm install workbox-core workbox-precaching workbox-routing \
workbox-strategies workbox-expiration
npm install --save-dev workbox-build
This tutorial uses injectManifest. Workbox generates a revisioned list of build files and injects it into a service worker you control. Use generateSW when default behavior is enough; use injectManifest when you need custom routes, fallbacks, or web push. That distinction is documented in workbox-build.
Register the service worker
Add a production-only registration to the browser entry file:
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.info('Service worker scope:', registration.scope);
} catch (error) {
console.error('Service worker registration failed', error);
}
});
}
The generated sw.js should be served from the site root. Service worker scope follows its URL path, so placing it in a nested assets directory can prevent it from controlling the entire application.
Create the Workbox service worker
Create src/sw.js. Precache the build manifest, remove outdated caches, and claim open pages after activation:
import { clientsClaim } from 'workbox-core';
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
self.skipWaiting();
clientsClaim();
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);
Only files emitted by the current build belong in the precache. Avoid precaching huge videos, personalized pages, or every image in the product catalog. Oversized precaches slow installation and waste storage.
Use the right runtime caching strategy
Use Network First for page navigation. The browser receives current HTML when online and falls back to a previously cached response during a network failure:
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({
cacheName: 'pages-v1',
networkTimeoutSeconds: 4,
})
);
Stale While Revalidate is a good fit for same-origin CSS and JavaScript that are not already precached:
registerRoute(
({ request, url }) =>
url.origin === self.location.origin &&
['style', 'script', 'worker'].includes(request.destination),
new StaleWhileRevalidate({ cacheName: 'assets-v1' })
);
Images change less frequently, so Cache First saves bandwidth. Add expiration boundaries to prevent unlimited storage growth:
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images-v1',
plugins: [
new ExpirationPlugin({
maxEntries: 80,
maxAgeSeconds: 30 * 24 * 60 * 60,
purgeOnQuotaError: true,
}),
],
})
);
Cache APIs selectively
Cache only safe GET responses with explicit rules. A public catalog endpoint might use Network First:
registerRoute(
({ request, url }) =>
request.method === 'GET' &&
url.origin === self.location.origin &&
url.pathname.startsWith('/api/catalog'),
new NetworkFirst({
cacheName: 'catalog-api-v1',
networkTimeoutSeconds: 3,
plugins: [new ExpirationPlugin({ maxEntries: 40, maxAgeSeconds: 3600 })],
})
);
Do not cache authenticated responses by default. Cached private data can remain on a shared device after logout. Never cache mutations such as POST, PUT, PATCH, or DELETE as ordinary responses; reliable offline writes require a deliberate synchronization and conflict-resolution design.
Inject the precache manifest after Vite builds
Create scripts/build-sw.mjs:
import { injectManifest } from 'workbox-build';
const result = await injectManifest({
swSrc: 'src/sw.js',
swDest: 'dist/sw.js',
globDirectory: 'dist',
globPatterns: ['**/*.{html,js,css,svg,png,webp,woff2}'],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
});
console.log(`Precached ${result.count} files (${result.size} bytes)`);
Run it after the main build:
{
"scripts": {
"build": "vite build && node scripts/build-sw.mjs"
}
}
Handle updates without surprising users
skipWaiting() activates a new worker quickly, but an open page may still contain old JavaScript. For complex applications, detect controllerchange and show a “new version available” prompt before reloading. Avoid automatic reload loops by storing a one-time flag in the page.
Version runtime cache names when their meaning changes, and delete obsolete caches during activation. Workbox already manages revisions for precached build files, which is safer than manually naming every asset.
Test the real failure modes
- Build and serve the production output over localhost or HTTPS.
- Open DevTools, verify that
sw.jscontrols the page, and inspect Cache Storage. - Reload once online so required resources are cached.
- Switch DevTools to Offline and navigate through previously visited routes.
- Deploy a changed build and verify that the update flow replaces old assets.
- Log out and confirm that private API responses are not recoverable from the cache.
An offline-first app is reliable when its caching policy is narrow, testable, and aligned with data freshness. Precache immutable build assets, choose runtime strategies per resource type, limit cache growth, protect private data, and test upgrades as carefully as the first installation.