Dynamic Caching — Caching Responses at Runtime for Offline
In this tutorial, you will learn about Dynamic Caching. We cover key concepts, practical examples, and best practices to help you master this topic.
Dynamic caching stores network responses as users browse, gradually building a complete offline copy of content without pre-caching every possible page at install time.
What You'll Learn
By the end of this tutorial, you will understand how dynamic caching works, strategies for limiting cache growth, how to handle cache eviction, and how to combine dynamic caching with precaching for a complete offline Strategy.
Why It Matters
Most PWAs have more content than can be pre-cached. Dynamic caching ensures every page a user visits becomes available offline on subsequent visits. Combined with precaching for critical resources, dynamic caching creates a comprehensive offline experience without the bloat of pre-caching everything.
Real-World Use
A documentation PWA pre-caches the app shell and homepage. As users browse different docs, each page is cached dynamically. After a week of use, the user has offline access to all documentation they actually read — without the developer pre-caching hundreds of pages that most visitors never view.
Dynamic Caching Flow
Dynamic Caching Process
User navigates to page /articles/how-to
↓
Page loads from network
↓
Service worker intercepts response
↓
┌──────────────────────────────────────┐
│ Check: Should this be cached? │
│ - Is it a GET request? │
│ - Is the response OK (200-299)? │
│ - Is it within cache limits? │
└──────────────────────────────────────┘
↓
If yes: store in dynamic cache
↓
If cache exceeds max entries:
evict oldest entries
↓
Page is now available offline
Think of dynamic caching like a DVR recording shows you watch. The DVR does not record every channel 24/7 (precaching everything). It records only the shows you actually watch (dynamic caching). If the DVR fills up, it deletes the oldest unwatched recordings.
Basic Dynamic Caching
const DYNAMIC_CACHE = 'dynamic-v1';
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
fetch(event.request)
.then(response => {
// Cache successful responses
if (response.ok) {
const clone = response.clone();
caches.open(DYNAMIC_CACHE).then(cache => {
cache.put(event.request, clone);
console.log('Dynamically cached:', event.request.url);
});
}
return response;
})
.catch(error => {
console.log('Fetch failed, checking cache:', error.message);
return caches.match(event.request)
.then(cached => {
if (cached) {
console.log('Serving from dynamic cache:', event.request.url);
return cached;
}
// Fallback to offline page
return caches.match('/offline.html');
});
})
);
});
Output:
Dynamically cached: https://example.com/articles/understanding-pwa
Dynamically cached: https://example.com/images/article-hero.jpg
Network failed, checking cache
Serving from dynamic cache: https://example.com/articles/understanding-pwa
Dynamic Cache with Size Limits
Unbounded dynamic caches fill storage. Implement eviction:
const DYNAMIC_CACHE = 'dynamic-v1';
const MAX_ENTRIES = 50;
async function addToDynamicCache(request, response) {
const cache = await caches.open(DYNAMIC_CACHE);
// Check current size
const keys = await cache.keys();
if (keys.length >= MAX_ENTRIES) {
// Evict oldest entries
const toDelete = keys.length - MAX_ENTRIES + 1;
console.log(`Evicting ${toDelete} oldest entries from dynamic cache`);
for (let i = 0; i < toDelete; i++) {
await cache.delete(keys[i]);
}
}
await cache.put(request, response);
console.log('Cached (dynamic):', request.url);
}
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
fetch(event.request)
.then(response => {
if (response.ok) {
const clone = response.clone();
addToDynamicCache(event.request, clone);
}
return response;
})
.catch(() => caches.match(event.request))
);
});
Dynamic Cache with TTL
Add time-based expiration to dynamic cache entries:
const DYNAMIC_CACHE = 'dynamic-v1';
const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours
async function addToDynamicCache(request, response, ttl = DEFAULT_TTL) {
const cache = await caches.open(DYNAMIC_CACHE);
// Store expiration metadata in a separate header
const headers = new Headers(response.headers);
const expiresAt = Date.now() + ttl;
headers.append('X-Cache-Expires', expiresAt.toString());
const cachedResponse = new Response(await response.clone().arrayBuffer(), {
status: response.status,
statusText: response.statusText,
headers: headers
});
await cache.put(request, cachedResponse);
}
async function getFromDynamicCache(request) {
const cache = await caches.open(DYNAMIC_CACHE);
const cached = await cache.match(request);
if (!cached) return null;
const expiresAt = parseInt(cached.headers.get('X-Cache-Expires') || '0');
if (Date.now() > expiresAt) {
console.log('Cache entry expired:', request.url);
await cache.delete(request);
return null;
}
return cached;
}
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
fetch(event.request)
.then(response => {
if (response.ok) {
addToDynamicCache(event.request, response);
}
return response;
})
.catch(() => {
return getFromDynamicCache(event.request).then(cached => {
if (cached) return cached;
return caches.match('/offline.html');
});
})
);
});
Selective Dynamic Caching
Not every response should be cached dynamically:
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Only cache same-origin GET requests
if (url.origin !== location.origin) return;
// Skip analytics, tracking, and auth endpoints
if (url.pathname.match(/^\/(analytics|auth|logout)/)) return;
// Skip non-cacheable content types
if (event.request.destination === 'document') {
// Cache HTML pages but not PDF downloads
if (url.pathname.endsWith('.pdf')) return;
}
event.respondWith(
fetch(event.request)
.then(response => {
if (response.ok) {
const clone = response.clone();
// Cache with category-based limits
if (url.pathname.startsWith('/images/')) {
addToCacheWithLimit('images-cache', event.request, clone, 30);
} else if (url.pathname.startsWith('/api/')) {
addToCacheWithLimit('api-cache', event.request, clone, 100, 3600000);
} else {
addToCacheWithLimit('pages-cache', event.request, clone, 20);
}
}
return response;
})
.catch(() => caches.match(event.request))
);
});
async function addToCacheWithLimit(cacheName, request, response, maxEntries, ttl) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length >= maxEntries) {
await cache.delete(keys[0]);
}
if (ttl) {
const headers = new Headers(response.headers);
headers.append('X-Cache-Expires', (Date.now() + ttl).toString());
const timedResponse = new Response(await response.arrayBuffer(), {
status: response.status,
headers: headers
});
await cache.put(request, timedResponse);
} else {
await cache.put(request, response);
}
}
Combining Pre-Cache and Dynamic Cache
The best strategy uses both:
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
// Check precache first, then fall through to dynamic
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
// Dynamic cache with limit
caches.open('dynamic-v1').then(cache => {
cache.keys().then(keys => {
if (keys.length >= 50) {
cache.delete(keys[0]);
}
cache.put(event.request, clone);
});
});
}
return response;
}).catch(() => {
return caches.match('/offline.html');
});
})
);
});
Common Mistakes
- No cache size limits. Dynamic caches grow indefinitely. Always set max entries or max age to prevent filling user storage.
- Caching responses that should not be cached. API errors, auth tokens, and user-specific data should not be cached dynamically.
- No cache eviction strategy. Old content stays forever. Implement FIFO, LRU, or TTL-based eviction.
- Caching the same resource in multiple caches. A page cached in both 'pages' and 'dynamic' caches wastes storage. Use a single cache or deduplicate.
- Not verifying response.ok before caching. Error responses (4xx, 5xx) cached dynamically serve broken content offline.
Practice Questions
- How does dynamic caching differ from precaching?
- Why should you limit dynamic cache size, and what eviction strategies can you use?
- How would you implement time-based expiration for dynamically cached content?
- What types of responses should be excluded from dynamic caching?
- How do precaching and dynamic caching work together?
Challenge: Create three dynamic caches with different limits: images-cache (max 30, 7-day TTL), api-cache (max 100, 1-hour TTL), and pages-cache (max 20, no TTL). Implement FIFO eviction for each. Log all cache operations. Test by loading pages, checking cache storage, and verifying eviction.
FAQ
Mini Project
Create a dynamic caching system with three named caches: pages (HTML, max 20, FIFO), assets (CSS/JS/fonts, max 100, 7-day TTL), and media (images/videos, max 30, 30-day TTL). Implement selective caching that skips auth endpoints and error responses. Log every cache operation. Verify with DevTools Cache Storage and Network panels.
What's Next
You have mastered browser caching. Now learn about IndexedDB for offline data — storing structured data like offline database records for complex offline functionality.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro