Agado Dev

Dynamic Caching with Service Workers: A Practical Guide

Building a web application as a Progressive Web App (PWA) introduces the powerful capability of offline access, providing seamless experiences even in unreliable network conditions. One crucial aspect of building a robust PWA is caching, which ensures that all necessary assets are available when users are offline. This article dives into dynamic asset caching using Service Workers, focusing on implementation via the Workbox library and Vite’s vite-plugin-pwa


The Challenge: Static vs. Dynamic Assets

In our PWA, assets can be categorized as:

  1. Static Assets: These include files such as HTML, CSS, JS, and icons, which are determined at build time.

  2. Dynamic Assets: These are updated from our backoffice without requiring a new build or redeployment. Examples include new content such as audio files, images, and data fetched from APIs.

While caching static assets is straightforward with tools like Workbox, caching dynamic assets for offline usage poses unique challenges. The key question is: how do we efficiently cache dynamic assets while ensuring they stay updated?


The Implementation

For Caching static assets we will use the injectManifest feature of Workbox. Here is the vite.config.ts configuration using vite-plugin-pwa:

{
//...
injectManifest: {
globPatterns: ["**/*.{js,css,html,svg,png,ico}"],
swSrc: "assets-service-worker.ts"
},
// ...
}

Then, in our custom service worker implementation assets-service-worker.ts, we register the generated assets manifest using workbox-precaching

precacheAndRoute(self.__WB_MANIFEST);

For caching dynamic assets (assets that can evolve during a single build lifetime).Our API should expose a list of dynamic assets in the following format:

type AssetType = {
name: string;
url: string;
revision: string | null;
}

In our cases, dynamic asset include:

  • Image

  • Sound

  • REST endpoint


Key Challenges

One major challenge is keeping the dynamic asset list up-to-date. Instead of only updating the cache when the ServiceWorker installs, we refresh the list on each app load by:

  1. Listening for fetch events

  2. Triggering caching only for navigation requests (doc request).

Fetch Event for Dynamic Assets

The Service Worker listens for navigation requests and triggers dynamic asset caching:

self.addEventListener("fetch", (event) => {
const isNavigate = event.request.mode === "navigate";
if (navigator.onLine && isNavigate) {
fetchAndCacheDynamicAssets();
}
});

Fetch and Populate Cache

Dynamic assets lists are fetched from the API and cached manually:

async function fetchAndCacheDynamicAssets() {
try {
const response = await restClient.assets.search({
query: { limit: 100_000 },
});
if (response.status !== 200) {
throw new Error("Failed to fetch assets for preloading");
}
const assets = response.body;
// Manually cache assets
const dynAssetsCache = await caches.open(DYNAMIC_CACHE_NAME);
const storeAssetsInCachePromises = assets.map(async ({ url, revision }) => {
const assetUrlWithRevision = revision ? `${url}?rev=${revision}` : url;
const cacheMatch = await dynAssetsCache.match(assetUrlWithRevision);
if (!cacheMatch) {
const assetResponse = await fetch(url);
if (assetResponse.ok) {
await dynAssetsCache.put(assetUrlWithRevision, assetResponse);
}
}
});
await Promise.all(storeAssetsInCachePromises);
} catch (error) {
console.error("Failed to fetch assets for preloading", error);
}
}

And finally, we register a Workbox route for serving this dynamic assets from cache

registerRoute(
({ request, url }) => {
return (
url.pathname.startsWith("/api") ||
request.destination === "image" ||
request.destination === "audio"
);
},
new StaleWhileRevalidate({
cacheName: DYNAMIC_CACHE_NAME,
matchOptions: { ignoreSearch: true }, // to ignore the revision ("rev") query param
})
);

Last part of our serviceWorker implementation will be to serve all navigation requests with the `index.html` file to maintain PWA functionality:

registerRoute(
new NavigationRoute(createHandlerBoundToURL("/index.html"))
);

Below is the final ServiceWorker code:

import { createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { StaleWhileRevalidate } from "workbox-strategies";
import { restClient } from "./infrastructure/query.client";
declare const self: ServiceWorkerGlobalScope;
const DYNAMIC_CACHE_NAME = "dynamic-assets-cache";
// Fetch asset list during doc (index.html) request
self.addEventListener("fetch", (event) => {
const isNavigate = event.request.mode === "navigate";
if (navigator.onLine && isNavigate) {
fetchAndCacheDynamicAssets();
}
});
precacheAndRoute(self.__WB_MANIFEST);
// All Navigation requests will be served with the index.html file (no SSR)
registerRoute(
new NavigationRoute(createHandlerBoundToURL("/index.html"))
);
registerRoute(
({ request, url }) => {
return (
url.pathname.startsWith("/api") ||
request.destination === "image" ||
request.destination === "audio"
);
},
new StaleWhileRevalidate({
cacheName: DYNAMIC_CACHE_NAME,
matchOptions: { ignoreSearch: true }, // to ignore the "rev" query param
})
);
async function fetchAndCacheDynamicAssets() {
try {
const response = await restClient.assets.search({
query: { limit: 100_000 },
});
if (response.status !== 200) {
throw new Error("Failed to fetch assets for preloading");
}
const assets = response.body;
// Manually cache assets
const dynAssetsCache = await caches.open(DYNAMIC_CACHE_NAME);
const storeAssetsInCachePromises = assets.map(async ({ url, revision }) => {
const assetUrlWithRevision = revision ? `${url}?rev=${revision}` : url;
const cacheMatch = await dynAssetsCache.match(assetUrlWithRevision);
if (!cacheMatch) {
const assetResponse = await fetch(url);
if (assetResponse.ok) {
await dynAssetsCache.put(assetUrlWithRevision, assetResponse);
}
}
});
await Promise.all(storeAssetsInCachePromises);
} catch (error) {
console.error("Failed to fetch assets for preloading", error);
}
}

Conclusion

By combining Workbox with a custom Service Worker, we can efficiently manage dynamic assets in PWAs. This approach ensures assets remain up-to-date and available offline, enhancing user experience. Preloading static assets with `injectManifest` and dynamically caching assets during app usage strikes the right balance between efficiency and flexibility.