Stale-While-Revalidate — Fastest Fresh Content Strategy
In this tutorial, you will learn about Stale. We cover key concepts, practical examples, and best practices to help you master this topic.
Stale-while-revalidate serves cached content instantly while fetching fresh data in the background, combining sub-millisecond cache speed with up-to-date content for the best of both worlds.
What You'll Learn
By the end of this tutorial, you will understand how stale-while-revalidate works, when to use it, how to implement it with the Cache API, and how to handle revalidation timing and conflicts.
Why It Matters
Users want both speed and freshness. Cache-first is fast but serves stale data. Network-first is fresh but slow. Stale-while-revalidate eliminates this tradeoff: the user sees content instantly (from cache) while the app fetches updates silently in the background.
Real-World Use
A news PWA uses stale-while-revalidate for article pages. When a user opens an article, it renders instantly from cache. The service worker fetches the latest version from the network and updates the cache silently. If the user refreshes, they see the updated version. Reading feels instant even on slow connections.
How Stale-While-Revalidate Works
Stale-While-Revalidate Flow
Request comes in
↓
┌──────────────────────────────────────────┐
│ Check cache AND start network fetch │
│ simultaneously │
└──────────────────────────────────────────┘
↓ ↓
┌────────────┐ ┌──────────────┐
│ Cache hit │ │ Cache miss │
└─────┬──────┘ └──────┬───────┘
↓ ↓
Return cached Wait for network
immediately ↓
↓ Return fresh
Background network (and cache it)
completes after ↓
rendering Done
↓
Update cache with
fresh response
↓
Done (next request
gets fresh data)
Think of stale-while-revalidate like a restaurant with a display case (cache) and a kitchen (network). When you order, they serve food from the display case immediately (instant). The kitchen prepares fresh food in the background and restocks the display case. Next time, you get fresh food that was prepared between orders.
Basic Implementation
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then(cachedResponse => {
// Start network fetch in background
const fetchPromise = fetch(event.request)
.then(networkResponse => {
if (networkResponse.ok) {
const clone = networkResponse.clone();
caches.open('dynamic-cache').then(cache => {
cache.put(event.request, clone);
console.log('Background cache update:', event.request.url);
});
}
return networkResponse;
})
.catch(error => {
console.log('Background fetch failed:', error.message);
return cachedResponse;
});
// Return cached immediately, or wait for network if no cache
if (cachedResponse) {
console.log('Serving stale:', event.request.url);
return cachedResponse;
}
console.log('No cache, waiting for network:', event.request.url);
return fetchPromise;
})
);
});
Output:
Serving stale: https://example.com/api/articles
Background cache update: https://example.com/api/articles
Serving stale: https://example.com/styles/main.css
Serving stale: https://example.com/images/hero.jpg
No cache, waiting for network: https://example.com/api/new-feature
Background cache update: https://example.com/api/new-feature
Stale-While-Revalidate with Max-Age
You can control staleness by serving from cache only if it is recent enough:
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const MAX_AGE = 5 * 60 * 1000; // 5 minutes in milliseconds
event.respondWith(
caches.match(event.request).then(cachedResponse => {
// Check if cached response is fresh enough
const isStale = cachedResponse && !isFresh(cachedResponse, MAX_AGE);
if (cachedResponse && !isStale) {
console.log('Fresh enough, no revalidation needed');
return cachedResponse;
}
// Fetch from network (for stale or missing cache)
const fetchPromise = fetch(event.request)
.then(networkResponse => {
if (networkResponse.ok) {
// Add timestamp header for freshness tracking
const headers = new Headers(networkResponse.headers);
headers.append('X-Cache-Timestamp', Date.now().toString());
const freshResponse = new Response(networkResponse.clone().body, {
status: networkResponse.status,
statusText: networkResponse.statusText,
headers: headers
});
caches.open('dynamic-cache').then(cache => {
cache.put(event.request, freshResponse);
});
}
return networkResponse;
})
.catch(() => cachedResponse);
// Return stale cached immediately if available
if (cachedResponse) {
console.log('Serving stale (exceeded max-age):', event.request.url);
return cachedResponse;
}
return fetchPromise;
})
);
});
function isFresh(response, maxAge) {
const cachedTime = response.headers.get('X-Cache-Timestamp');
if (!cachedTime) return false;
return (Date.now() - parseInt(cachedTime)) < maxAge;
}
Stale-While-Revalidate with Progress Notification
You can notify the page when fresh data arrives:
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request)
.then(networkResponse => {
if (networkResponse.ok) {
const clone = networkResponse.clone();
caches.open('dynamic-cache').then(cache => {
cache.put(event.request, clone);
});
// Notify page about fresh data
if (cachedResponse) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'CACHE_UPDATED',
url: event.request.url,
timestamp: Date.now()
});
});
});
}
}
return networkResponse;
})
.catch(() => cachedResponse);
return cachedResponse || fetchPromise;
})
);
});
Stale-While-Revalidate for Lists with Pagination
For paginated content, stale-while-revalidate works well because users typically start with page 1 and navigate forward:
const CACHE_NAME = 'list-cache-v1';
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Apply to paginated list endpoints
if (url.pathname.startsWith('/api/list')) {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request)
.then(networkResponse => {
if (networkResponse.ok) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
})
.catch(() => cachedResponse);
return cachedResponse || fetchPromise;
});
})
);
}
});
When to Use Stale-While-Revalidate
This Strategy is ideal for:
- Content that changes but not critically: News articles, blog posts, product descriptions
- API lists: Articles list, product catalog, search results
- User profiles: Display name, avatar, bio (low sensitivity to staleness)
- Configuration files: Feature flags, settings, theme preferences
- Images that update occasionally: Avatar photos, cover images
Avoid stale-while-revalidate for:
- Authentication tokens: Must be current
- Financial data: Stock prices, account balances
- Real-time collaboration: Chat messages, shared cursors
- Form validation responses: Must reflect server state
- Time-sensitive offers: Discount counts, auction timers
Cache Update Strategies
// Update notification in your page
navigator.serviceWorker.addEventListener('message', event => {
if (event.data.type === 'CACHE_UPDATED') {
console.log('Cache updated for:', event.data.url);
// Show subtle indicator that content refreshed
const indicator = document.getElementById('fresh-data-indicator');
if (indicator) {
indicator.textContent = 'Content updated';
indicator.classList.add('visible');
setTimeout(() => indicator.classList.remove('visible'), 3000);
}
}
});
Common Mistakes
- Serving stale data indefinitely. Without max-age checking, stale-while-revalidate serves data that is days or weeks old. Add a freshness threshold.
- Network fetch failing silently when cache exists. If the background fetch always fails, the cache is never updated. Monitor fetch failures and alert when they persist.
- Updating cache after page navigation. If the user navigates away before the background fetch completes, the cache is still updated. This is usually fine, but consider canceling unnecessary fetches.
- Not handling cache miss during background fetch. If there is no cached response and the network fetch fails, the user gets nothing. Handle this with a fallback.
- Conflicting updates for the same resource. If two requests for the same resource fire simultaneously, you may have race conditions. Debounce or deduplicate requests.
Practice Questions
- How does stale-while-revalidate differ from cache-first?
- What happens if the background revalidation fetch fails?
- Why would you add a max-age check to stale-while-revalidate?
- How can you notify the page that fresh data has been cached?
- What types of content should NOT use stale-while-revalidate?
Challenge: Implement stale-while-revalidate with a 2-minute max-age. Notify the page when fresh data arrives. Add deduplication so that if 5 requests for the same URL fire in quick succession, only one background fetch occurs.
FAQ
Mini Project
Build a service worker that uses stale-while-revalidate for a news article API. Add a 5-minute freshness threshold. Notify the page when new articles are available in the cache. In the page JavaScript, listen for update messages and show a "New articles available" banner. Test by loading articles, updating the server data, and refreshing to see the banner.
What's Next
You understand the hybrid strategy. Now learn extreme cases with cache-only and network-only strategies for situations where you need strict control.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro