Why “by asset type” matters
A caching strategy is not a single switch you flip for an entire Progressive Web App. Different resources behave differently: some rarely change (logos), some change frequently (API responses), some are large and expensive (videos), and some are critical to first render (CSS). Selecting a strategy by asset type means you decide, for each category of request, what you optimize for: fastest startup, freshest data, lowest bandwidth, resilience to flaky networks, or storage efficiency.
In practice, “asset type” usually maps to a combination of: request destination (script, style, image, font, document), URL patterns (e.g., /api/), response headers (Cache-Control, ETag), and business semantics (user-specific vs public data). Your service worker can route requests to different caching logic based on these signals.
Key trade-offs you will balance
- Freshness vs speed: Network-first gives fresher data but may be slower or fail offline; cache-first is fast and offline-friendly but can serve stale content.
- Consistency vs resilience: For critical flows (checkout, auth), you may prefer network-only with explicit offline messaging rather than silently serving cached responses.
- Storage vs performance: Aggressive caching improves speed but can exceed storage quotas; you need limits and eviction policies.
- Public vs private: Caching user-specific responses can leak data across accounts on shared devices if not carefully scoped and cleared.
Common strategies (as building blocks)
You will combine a small set of strategies across asset types. Keep the implementations small and testable.
- Cache-first: Try cache; if missing, fetch from network and store. Best for versioned static assets.
- Network-first: Try network; if it fails or times out, fall back to cache. Best for dynamic data where freshness matters.
- Stale-while-revalidate (SWR): Serve cached immediately (if present) and update cache in the background. Best for “mostly fresh” content like lists, avatars, and non-critical API responses.
- Cache-only: Only from cache. Useful for fully offline bundles or when you intentionally block network for certain requests.
- Network-only: Always network. Useful for sensitive endpoints, analytics beacons (often you’ll use Background Sync instead), and one-off operations.
Asset-type strategy map (recommended defaults)
The table below is a practical starting point. You will adjust based on your app’s update frequency and user expectations.
- HTML documents (navigation requests): Network-first with offline fallback (or SWR for content sites). Avoid long-lived caching of HTML unless you have strong versioning and update control.
- CSS/JS (build artifacts): Cache-first with long TTL, ideally with content-hashed filenames (e.g., app.3f2c1.js). This gives instant startup and safe updates.
- Fonts: Cache-first with long TTL. Fonts are stable and expensive to re-download.
- Images: Cache-first with size limits and expiration. Consider SWR for frequently updated images (profile pictures) and avoid caching very large images unless needed.
- API GET responses (public, cacheable): SWR or network-first with short timeout. Use cache keys that include query params; add max entries and max age.
- API GET responses (user-specific): Network-first or SWR with strict scoping; clear on logout; consider not caching at all for sensitive data.
- API non-GET (POST/PUT/DELETE): Network-only; if you need offline support, queue requests (separate topic) rather than caching responses.
- Third-party resources: Prefer not to cache unless you trust stability and have CORS/opaque response considerations. If you do, use cache-first with tight limits.
- Media (audio/video): Usually network-only or range-request aware caching; be cautious with storage and partial content.
Step-by-step: classify requests by asset type
Before writing caching code, define your routing rules. A robust approach is to classify each request using multiple signals.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Step 1: Identify request categories
Create a list of categories that match your app. Example categories:
- Build assets: /assets/*.js, /assets/*.css
- Fonts: *.woff2
- Images: /images/, *.png, *.jpg, *.webp, *.svg
- Navigation HTML: request.mode === 'navigate'
- API: /api/ (GET vs non-GET)
- Third-party: different origin
Step 2: Decide strategy per category
Write down the strategy and constraints per category: cache name, max entries, max age, and whether to allow opaque responses. Example decisions:
- assets-v1: cache-first, no expiration needed if filenames are hashed
- fonts-v1: cache-first, maxAge 365 days
- images-v1: cache-first, maxEntries 200, maxAge 30 days
- api-v1: SWR, maxEntries 100, maxAge 5 minutes
- pages-v1: network-first with 3s timeout, fallback to offline page
Step 3: Implement small strategy helpers
Even if you use a library later, it helps to understand the primitives. Below are minimal helpers using the Cache Storage API. They are intentionally simple; you can extend them with expiration logic and timeouts.
async function cacheFirst(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); if (cached) return cached; const response = await fetch(request); if (response.ok) cache.put(request, response.clone()); return response;}async function networkFirst(request, cacheName) { const cache = await caches.open(cacheName); try { const response = await fetch(request); if (response.ok) cache.put(request, response.clone()); return response; } catch (err) { const cached = await cache.match(request); if (cached) return cached; throw err; }}async function staleWhileRevalidate(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); const networkPromise = fetch(request).then(response => { if (response.ok) cache.put(request, response.clone()); return response; }).catch(() => null); return cached || (await networkPromise);}Step 4: Route fetch events by destination and URL
Use request.destination where possible; it is more reliable than file extensions. Combine with URL patterns for APIs and special cases.
self.addEventListener('fetch', event => { const req = event.request; const url = new URL(req.url); // Only handle same-origin by default if (url.origin !== self.location.origin) return; // Navigation (HTML) if (req.mode === 'navigate') { event.respondWith(networkFirst(req, 'pages-v1')); return; } // Static build assets if (req.destination === 'script' || req.destination === 'style') { event.respondWith(cacheFirst(req, 'assets-v1')); return; } // Fonts if (req.destination === 'font') { event.respondWith(cacheFirst(req, 'fonts-v1')); return; } // Images if (req.destination === 'image') { event.respondWith(cacheFirst(req, 'images-v1')); return; } // API GET if (url.pathname.startsWith('/api/') && req.method === 'GET') { event.respondWith(staleWhileRevalidate(req, 'api-v1')); return; } // Default: pass through // (or choose a conservative strategy) });This routing is the core of “selection by asset type.” The rest of the chapter focuses on how to choose the right strategy for each category and what pitfalls to avoid.
HTML documents: treat navigations differently
HTML is special because it often references the latest build assets and may include user-specific server-rendered content. If you cache HTML too aggressively, users can get stuck on an old version that references missing JS/CSS, or they may see stale personalized content.
Recommended approach
- Network-first for navigations, with a short timeout if you want fast fallback.
- Cache the last successful HTML so offline reload works.
- Provide a dedicated offline fallback page for first-time offline visits (the fallback itself should be cached).
If your app is a content site (articles, docs) where HTML is the content, SWR can be a good fit: users get instant cached pages and the cache updates in the background. For transactional apps, network-first is usually safer.
CSS and JavaScript: cache-first with versioned URLs
Build artifacts are ideal for cache-first because they are required for fast startup and are typically immutable when you use content hashing. The key is that the URL must change when the content changes. If you serve /app.js without hashing and you cache-first, users can be stuck with old code until the cache is cleared.
Practical checklist for build assets
- Ensure filenames include a content hash (e.g., main.8c1e2.css).
- Serve with long-lived HTTP caching headers (Cache-Control: public, max-age=31536000, immutable) to reduce network requests even without the service worker.
- Use cache-first in the service worker to guarantee offline availability.
Because hashed assets are immutable, you can keep them in a dedicated cache (assets-v1) and periodically delete old caches when you bump versions.
Fonts: cache-first, but watch CORS and formats
Fonts are large, reused across pages, and rarely change. Cache-first is almost always correct. If fonts are served from a different origin (e.g., a CDN), you may get opaque responses depending on CORS, which can be cached but are harder to validate and can increase storage usage.
Practical tips
- Prefer self-hosting fonts to avoid opaque responses and improve control.
- Use modern formats (woff2) to reduce size.
- Keep a separate fonts cache so you can apply long retention without mixing with volatile data.
Images: cache-first with limits and transformation awareness
Images are a broad category: icons, product photos, user avatars, and responsive variants. Cache-first is a good default because images are expensive and benefit from offline availability. But you must control growth: image caches can balloon quickly.
Choose a strategy based on image semantics
- App icons, logos, UI illustrations: cache-first, long retention (they behave like static assets).
- Content images (product photos, article images): cache-first with max entries and max age.
- Frequently updated images (avatars): SWR so users see something instantly but updates appear soon.
Be careful with “same URL, different bytes”
If your server returns different image bytes for the same URL based on headers (Accept for webp/avif, DPR, width), you can accidentally cache one variant and serve it in the wrong context. Mitigations include:
- Use distinct URLs per variant (e.g., /img/photo?w=400&fmt=webp).
- Include query parameters in the cache key (the Cache API does by default).
- Ensure the server sets Vary headers appropriately; note that the Cache API does not automatically manage Vary the same way as HTTP caches, so distinct URLs are the most reliable.
API responses: pick strategy by data volatility and sensitivity
API caching is where “asset type” becomes “data type.” Two endpoints can both be JSON but require different strategies. Start by separating:
- Public vs user-specific
- Read (GET) vs write (POST/PUT/DELETE)
- High volatility vs low volatility
- Critical correctness vs “eventually consistent” acceptable
Public, cacheable GET endpoints
Examples: product catalog, public blog feed, country list. SWR is often ideal: the app loads quickly from cache, and users get updates shortly after. Add a max age so you don’t serve very old data indefinitely.
When using SWR, consider adding UI cues for “updated in background” if data changes can surprise users (e.g., prices). For price-sensitive data, prefer network-first with a short timeout.
User-specific GET endpoints
Examples: /api/me, /api/orders, /api/messages. Caching can improve perceived performance, but you must prevent cross-user leakage and stale sensitive data.
- Use a cache name that includes a user identifier (if you have it safely available) or clear the cache on logout.
- Avoid caching endpoints that include secrets or one-time tokens.
- Prefer network-first for correctness, with cache fallback only for offline read-only views.
Write operations (non-GET)
Do not treat writes as cacheable assets. A cache is not a queue. For offline writes, you typically store the intent (request body) in IndexedDB and replay later; that is a different mechanism than caching. In the fetch handler, you can still choose to let non-GET requests pass through untouched (network-only) and show an offline error if needed.
Third-party resources: decide if you should cache at all
Third-party requests include analytics scripts, embedded widgets, map tiles, and CDN-hosted libraries. Caching them can improve performance, but it introduces risks:
- Opaque responses: If the response is opaque, you can cache it but cannot inspect it, and it may consume significant storage.
- Update unpredictability: Third-party URLs may change content without changing the URL, which breaks cache-first assumptions.
- Privacy and compliance: Caching third-party content can extend its lifetime on device.
A conservative approach is to only cache third-party resources that are immutable (versioned URLs) and critical to your app, and to apply strict limits.
Media and large files: avoid naive caching
Audio and video are large and often requested with range requests (partial content). Naively caching them can lead to broken playback or huge storage usage. Unless you have a specific offline media feature, prefer network-only and rely on the browser’s HTTP cache. If you do need offline media, you typically implement a dedicated download manager with explicit user control and storage accounting.
Adding constraints: expiration and cache size limits
Choosing a strategy by asset type is incomplete without constraints. Without limits, caches grow forever. You can implement basic eviction by tracking timestamps in IndexedDB or by periodically trimming entries.
Simple max-entries trimming (practical pattern)
The Cache API does not provide “list by last accessed” directly, but you can approximate by trimming based on insertion order using cache.keys(). This is not perfect LRU, but it is often good enough for image caches.
async function trimCache(cacheName, maxEntries) { const cache = await caches.open(cacheName); const keys = await cache.keys(); if (keys.length <= maxEntries) return; const deleteCount = keys.length - maxEntries; for (let i = 0; i < deleteCount; i++) { await cache.delete(keys[i]); }}Call trimCache after adding a new entry to caches like images-v1 or api-v1.
Max-age expiration (conceptual approach)
To expire entries by age, you need to store metadata (timestamp) alongside the cached response, typically in IndexedDB keyed by request URL. On each read, check the timestamp and decide whether to treat the cache as valid. This is more code, but it is the difference between “offline forever” and “offline with reasonable freshness.” If you prefer less custom code, a routing library can provide expiration plugins, but the underlying concept remains: apply max age per asset type.
Putting it together: a practical selection plan
Use the following workflow to select strategies by asset type without over-engineering.
Step 1: Inventory your requests
- Open DevTools Network tab and record a typical session.
- Group requests into: navigations, scripts, styles, fonts, images, API GET, API non-GET, third-party.
- Note which ones are essential for first render and which are optional.
Step 2: Assign strategies with explicit reasons
- For each group, write “we choose X because Y” (e.g., “images cache-first because they are heavy and reused”).
- Mark any endpoints that must never be served stale (e.g., account balance) and avoid SWR there.
- Mark any data that must not be stored (e.g., sensitive personal data) and keep it network-only.
Step 3: Define cache boundaries
- Create separate caches per asset type (assets, pages, images, fonts, api).
- Set different limits per cache (images: many entries; api: fewer entries, shorter age).
- Plan how you will clear user-specific caches on logout.
Step 4: Implement routing and test scenarios
- Test first load online, then reload offline.
- Test slow network (throttling) to see if network-first causes delays; add timeouts if needed.
- Test updates: ensure new builds load correctly and old caches don’t break the app.
- Test multi-user on same device (log out/in) to ensure no cached private data leaks.
Example: strategy selection for a typical app
Consider an app with a dashboard, a product list, and user messages.
- Dashboard HTML: network-first. Reason: should reflect latest server-rendered state; fallback to cached dashboard offline.
- JS/CSS: cache-first. Reason: hashed build assets; required for fast startup.
- Product images: cache-first + trim to 200. Reason: heavy and reused; storage bounded.
- Product list API (/api/products): SWR + max age 5 minutes. Reason: list can be slightly stale; fast display matters.
- Messages API (/api/messages): network-first. Reason: freshness and correctness important; offline fallback acceptable for last known messages if you choose to cache.
- POST /api/messages: network-only. Reason: writes must not be faked by cache; handle offline separately.
This selection gives a fast, resilient UI while keeping correctness where it matters.