PWA Image Optimization: Offline Performance and Smart Caching
Master image optimization in Progressive Web Apps. Caching strategies, offline functionality and best practices for PWAs in 2025.
Progressive Web Apps (PWAs) have revolutionized the mobile experience, but their true potential is unlocked when you properly optimize images for offline functionality and smart caching. In 2025, a well-optimized PWA can outperform many native apps.
🚀 Key elements for high-performance PWAs:
- Strategic caching: Service Workers with Cache-First policies for critical images
- Modern formats: WebP/AVIF with automatic offline fallbacks
- Advanced lazy loading: Intersection with intelligent pre-cache
- Adaptive sizing: Responsive images with srcset for each viewport
- Direct optimization: Compress for PWA | Responsive resize
Why are images critical in PWAs?
Images typically represent 60-80% of a PWA's total weight. Unlike traditional web applications, PWAs must work perfectly offline and provide native experiences, which requires specific optimization strategies:
Unique PWA challenges:
- Offline functionality: Images must be available without connection
- Limited storage: Cache Storage API has size restrictions
- Efficient updates: Intelligent management of image versions
- Mobile performance: Optimization for resource-constrained devices
Caching strategies for PWA images
1. Cache-First for critical images
This strategy prioritizes speed by serving content from cache when available:
// Service Worker - Cache-First Strategy
self.addEventListener('fetch', event => {
if (event.request.destination === 'image') {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then(response => {
// Automatically cache new images
const responseClone = response.clone();
caches.open('images-v1')
.then(cache => cache.put(event.request, responseClone));
return response;
});
})
);
}
});
2. Network-First for dynamic content
For images that change frequently (avatars, user-generated content):
// Hybrid strategy with timeout
const CACHE_TIMEOUT = 3000;
self.addEventListener('fetch', event => {
if (event.request.url.includes('/dynamic-images/')) {
event.respondWith(
Promise.race([
fetch(event.request),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), CACHE_TIMEOUT)
)
])
.then(response => {
// Update cache with new version
const responseClone = response.clone();
caches.open('dynamic-images-v1')
.then(cache => cache.put(event.request, responseClone));
return response;
})
.catch(() => {
// Fallback to cache if network fails
return caches.match(event.request);
})
);
}
});
3. Stale-While-Revalidate for optimal balance
Serves from cache immediately but updates in background:
// Best user experience with transparent updates
self.addEventListener('fetch', event => {
if (event.request.url.includes('/content-images/')) {
event.respondWith(
caches.open('content-images-v1').then(cache => {
return cache.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Return cache immediately if exists, else wait for network
return cachedResponse || fetchPromise;
});
})
);
}
});
Real success case
An e-commerce PWA implemented strategic caching for 2,000+ product images. Result: 85% reduction in network requests, 2.3x faster offline loading time, and 40% improvement in Core Web Vitals Score during traffic peaks.
Optimized image formats for PWAs
WebP with intelligent fallbacks
// Support detection and differentiated caching
const supportsWebP = () => {
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
return canvas.toDataURL('image/webp').indexOf('webp') !== -1;
};
// Service Worker with automatic format selection
self.addEventListener('fetch', event => {
if (event.request.destination === 'image') {
const url = new URL(event.request.url);
const acceptsWebP = event.request.headers.get('Accept')?.includes('webp');
if (acceptsWebP && !url.pathname.includes('.webp')) {
// Try WebP version first
const webpUrl = url.pathname.replace(/\.(jpg|jpeg|png)$/i, '.webp');
event.respondWith(
fetch(webpUrl)
.then(response => response.ok ? response : fetch(event.request))
.catch(() => fetch(event.request))
);
}
}
});
AVIF for premium PWAs
For PWAs that prioritize maximum quality with minimum weight:
// Format cascade with optimized cache
const imageFormats = ['avif', 'webp', 'jpg'];
async function getBestImage(basePath) {
for (const format of imageFormats) {
const url = `${basePath}.${format}`;
const cachedResponse = await caches.match(url);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(url);
if (response.ok) {
// Cache successful format
const cache = await caches.open('optimized-images-v1');
cache.put(url, response.clone());
return response;
}
} catch (error) {
continue; // Try next format
}
}
throw new Error('No image format available');
}
Responsive images for PWAs
Cache-optimized srcset
<picture>
<source
media="(min-width: 800px)"
srcset="hero-1200.webp 1200w, hero-800.webp 800w"
type="image/webp">
<source
media="(min-width: 800px)"
srcset="hero-1200.jpg 1200w, hero-800.jpg 800w">
<source
srcset="hero-480.webp 480w, hero-320.webp 320w"
type="image/webp">
<img
src="hero-480.jpg"
srcset="hero-480.jpg 480w, hero-320.jpg 320w"
sizes="(max-width: 600px) 100vw, 50vw"
alt="PWA-optimized hero image"
loading="lazy">
</picture>
Selective pre-cache by viewport
// Pre-load critical images based on current viewport
const preloadCriticalImages = () => {
const viewportWidth = window.innerWidth;
const imagesToPreload = [];
if (viewportWidth <= 480) {
imagesToPreload.push(
'/images/hero-320.webp',
'/images/logo-mobile.webp'
);
} else if (viewportWidth <= 800) {
imagesToPreload.push(
'/images/hero-480.webp',
'/images/hero-800.webp'
);
} else {
imagesToPreload.push(
'/images/hero-1200.webp',
'/images/hero-800.webp'
);
}
// Proactive caching
if ('serviceWorker' in navigator && 'caches' in window) {
caches.open('critical-images-v1').then(cache => {
cache.addAll(imagesToPreload);
});
}
};
// Execute on load and resize
window.addEventListener('load', preloadCriticalImages);
window.addEventListener('resize', debounce(preloadCriticalImages, 300));
Advanced lazy loading for PWAs
Intersection Observer with pre-cache
// Lazy loading with intelligent next viewport pre-loading
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// Load current image
if (img.dataset.src) {
img.src = img.dataset.src;
img.onload = () => {
img.classList.remove('loading');
img.classList.add('loaded');
};
}
// Pre-cache next visible image
const nextImage = img.closest('.image-container')?.nextElementSibling?.querySelector('img[data-src]');
if (nextImage && 'serviceWorker' in navigator) {
fetch(nextImage.dataset.src).then(response => {
if (response.ok) {
caches.open('preload-images-v1').then(cache => {
cache.put(nextImage.dataset.src, response);
});
}
});
}
observer.unobserve(img);
}
});
}, {
rootMargin: '50px 0px', // Start loading 50px before being visible
threshold: 0.1
});
// Apply to all lazy images
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
Progressive placeholder
/* CSS for smooth transitions */
.image-container {
position: relative;
overflow: hidden;
background: linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%);
background-size: 20px 20px;
background-position: 0 0, 10px 10px;
}
.image-container img {
transition: opacity 0.3s ease;
}
.image-container img.loading {
opacity: 0;
}
.image-container img.loaded {
opacity: 1;
}
/* Blur-up technique for better UX */
.image-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: var(--blur-placeholder);
background-size: cover;
filter: blur(5px);
transform: scale(1.1);
transition: opacity 0.3s ease;
}
.image-container.loaded::before {
opacity: 0;
}
Storage management and limits
Storage API quota monitoring
// Check available space and manage cache
async function manageStorageQuota() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
const usedSpace = estimate.usage || 0;
const availableSpace = estimate.quota || 0;
const usagePercentage = (usedSpace / availableSpace) * 100;
console.log(`Storage usage: ${usagePercentage.toFixed(1)}%`);
// Clean cache if exceeding 80%
if (usagePercentage > 80) {
await cleanOldCache();
}
}
}
async function cleanOldCache() {
const cacheNames = await caches.keys();
const oldCaches = cacheNames.filter(name =>
name.includes('-v') && !name.includes('-v1') // Keep only current version
);
await Promise.all(
oldCaches.map(cacheName => caches.delete(cacheName))
);
console.log(`Cleaned ${oldCaches.length} old caches`);
}
LRU (Least Recently Used) strategy
// Implement LRU for cached images
class ImageCacheLRU {
constructor(maxSize = 50) {
this.maxSize = maxSize;
this.cache = new Map();
this.usage = new Map();
}
async get(url) {
const cached = await caches.match(url);
if (cached) {
this.usage.set(url, Date.now());
return cached;
}
return null;
}
async put(url, response) {
// Remove oldest items if limit exceeded
if (this.cache.size >= this.maxSize) {
const oldestUrl = [...this.usage.entries()]
.sort((a, b) => a[1] - b[1])[0][0];
const cache = await caches.open('images-lru-v1');
await cache.delete(oldestUrl);
this.cache.delete(oldestUrl);
this.usage.delete(oldestUrl);
}
const cache = await caches.open('images-lru-v1');
await cache.put(url, response);
this.cache.set(url, true);
this.usage.set(url, Date.now());
}
}
const imageLRU = new ImageCacheLRU(100);
Optimize your PWA with FotoLince
FotoLince allows you to prepare perfectly optimized images for PWAs: modern formats, responsive sizes and intelligent compression. All processed locally to integrate directly into your caching strategy.
Optimize images for PWA →Performance metrics and monitoring
PWA-specific Core Web Vitals
// Monitor LCP specifically for images
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.element && entry.element.tagName === 'IMG') {
console.log('LCP Image:', {
url: entry.element.src,
renderTime: entry.renderTime,
loadTime: entry.loadTime,
size: entry.size
});
// Send metrics to analytics
gtag('event', 'lcp_image', {
'custom_parameter': entry.renderTime,
'image_url': entry.element.src
});
}
}
}).observe({entryTypes: ['largest-contentful-paint']});
// Monitor cache hit rate
let cacheHits = 0;
let cacheMisses = 0;
const originalFetch = window.fetch;
window.fetch = function(...args) {
const request = args[0];
if (typeof request === 'string' && request.includes('images/')) {
return caches.match(request).then(cachedResponse => {
if (cachedResponse) {
cacheHits++;
return cachedResponse;
} else {
cacheMisses++;
return originalFetch.apply(this, args);
}
});
}
return originalFetch.apply(this, args);
};
// Report metrics every 30 seconds
setInterval(() => {
const hitRate = (cacheHits / (cacheHits + cacheMisses)) * 100;
console.log(`Cache hit rate: ${hitRate.toFixed(1)}%`);
}, 30000);
Offline performance analysis
// Detect and optimize offline performance
window.addEventListener('online', () => {
console.log('Back online - syncing cached images');
// Sync pending images
syncPendingImages();
});
window.addEventListener('offline', () => {
console.log('Offline mode - serving from cache');
// Enable optimized offline mode
enableOfflineOptimizations();
});
function enableOfflineOptimizations() {
// Reduce dynamic image quality
document.documentElement.classList.add('offline-mode');
// Prioritize critical images in cache
prioritizeCriticalImages();
}
async function prioritizeCriticalImages() {
const criticalImages = document.querySelectorAll('img[data-critical="true"]');
const cache = await caches.open('critical-offline-v1');
for (const img of criticalImages) {
const response = await caches.match(img.src);
if (response) {
await cache.put(img.src, response);
}
}
}
Implementation best practices
1. Intelligent cache versioning
const CACHE_VERSION = 'v2.1.0';
const IMAGE_CACHE_NAME = `images-${CACHE_VERSION}`;
// Auto-cleanup of previous versions
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName.startsWith('images-') && cacheName !== IMAGE_CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
2. Graceful fallbacks
// Placeholder image when everything fails
const FALLBACK_IMAGE = '/images/placeholder.webp';
self.addEventListener('fetch', event => {
if (event.request.destination === 'image') {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
.catch(() => caches.match(FALLBACK_IMAGE))
);
}
});
3. Content-type configuration
const CACHE_STRATEGIES = {
'hero-images': { strategy: 'cacheFirst', ttl: 86400000 }, // 24h
'product-images': { strategy: 'staleWhileRevalidate', ttl: 3600000 }, // 1h
'user-avatars': { strategy: 'networkFirst', ttl: 1800000 } // 30min
};
Conclusion
PWA image optimization goes far beyond simple compression. It requires a comprehensive strategy that combines:
- Strategic caching adapted to content type
- Modern formats with automatic fallbacks
- Intelligent loading that anticipates user needs
- Efficient management of limited storage
- Continuous monitoring of offline and online performance
A well-optimized PWA can deliver experiences that rival native applications, especially under variable connectivity conditions typical of modern mobile usage.
Ready to create high-performance PWAs?
Use FotoLince to optimize your images specifically for PWAs: generate multiple formats, responsive sizes and compression settings adapted to each caching strategy.
Optimize images for PWA