Learn how to cache images correctly using Cache-Control headers, CDN edge caching, and cache-busting strategies — so browsers and CDNs serve your images instantly without hitting your origin server.
When a browser fetches an image, the server responds with HTTP headers that tell the browser — and any CDN in between — how to store and reuse that response. On the next request for the same URL, the browser can serve the image from its local cache without making a network request at all.
There are two distinct layers of caching for images:
Both layers are controlled by HTTP response headers. Getting those headers right is the entire game.
When a browser needs an image, it follows this decision tree:
If-None-Match or If-Modified-Since) to revalidate. If unchanged, server returns 304 Not Modified — still no re-download.Properly cached images directly improve LCP on repeat visits. If your hero image loads from disk cache instead of the network, LCP can drop from 1.5s to under 100ms. Google's Core Web Vitals field data includes repeat visits, so caching affects your real ranking signal.
The Cache-Control header is the primary tool for controlling how images are cached. It supersedes the older Expires header and gives you precise control over caching behavior.
For images, there are really only two patterns worth knowing:
| URL Type | Recommended Header | TTL |
|---|---|---|
Versioned / hashed (e.g. hero.a3f9b2.webp) | public, max-age=31536000, immutable | 1 year |
Non-versioned (e.g. logo.png) | public, max-age=86400, must-revalidate | 1 day |
| User-specific / private | private, max-age=3600 | 1 hour |
public: The response can be stored by any cache — browser, CDN, proxy. Required for CDN caching.private: Only the end user's browser may cache it. CDNs and proxies must not store it.max-age=N: Cache is considered fresh for N seconds. After that, the browser must revalidate.immutable: Tells the browser the resource will never change during its max-age window. Prevents unnecessary revalidation requests on reload. Use only with hashed/versioned URLs.must-revalidate: Once stale, the cache must not serve the resource without revalidating with the server (even if the server appears unreachable).no-cache: The cache must revalidate on every request before serving. The resource can still be stored and a 304 response avoids re-download.no-store: The response must not be stored anywhere. Use only for genuinely sensitive content — never for images.Use Cache-Control: public, max-age=31536000, immutable on all images served at content-hashed URLs. This is the gold standard: browsers cache forever, CDNs cache forever, and you always control freshness through the URL, not the TTL.
Never set Cache-Control: no-store on images. It forces a full re-download on every page load and completely defeats browser caching. This is a surprisingly common misconfiguration in security-conscious environments where the header is applied globally to all responses.
The paradox of aggressive caching is that when you update an image, cached browsers won't see the new version until the TTL expires. Cache-busting solves this by making updated images appear at new URLs, forcing browsers to fetch fresh.
Append a hash of the file's contents to the filename. When the image changes, the hash changes, and the URL changes — guaranteed cache invalidation.
# Original hero.webp # After content hashing (Webpack, Vite, etc.) hero.a3f9b2c1.webp # v1 hero.d7e4f8a2.webp # v2 (image changed, hash changed)
Most build tools (Webpack, Vite, Parcel) support content hashing out of the box via their output filename configuration.
Append a version parameter to the URL. Simpler to implement manually, but some CDNs and proxies ignore query strings when caching — making it less reliable than filename hashing.
<!-- Query string versioning (less reliable with some CDNs) --> <img src="/images/hero.webp?v=2" alt="Hero" />
If you can't change the URL, most CDNs (Cloudflare, Fastly, CloudFront) offer an API to manually purge specific cached objects. This is useful for CMS-managed images where filenames are fixed.
Vite automatically appends content hashes to build assets. Next.js uses content hashing for next/image and static imports. If you're using either framework, content-hashed image URLs are likely already handled — just make sure your server sets max-age=31536000, immutable for those paths.
A CDN caches your images on edge nodes distributed globally. When a user requests an image, it's served from the nearest node — often within milliseconds. Your origin server only receives the first request from each CDN region.
CDNs respect your origin's Cache-Control headers by default. If your origin sends public, max-age=31536000, the CDN will cache the image for up to one year. Most CDNs also let you set edge cache TTLs independently of browser cache TTLs via their dashboard or configuration rules.
| CDN | Default Behavior | Override Method |
|---|---|---|
| Cloudflare | Respects origin Cache-Control | Cache Rules in dashboard |
| AWS CloudFront | Respects origin Cache-Control | Cache Policies per behavior |
| Fastly | Respects origin Cache-Control | VCL or Compute@Edge |
| Bunny.net | Respects origin Cache-Control | Per-zone cache TTL setting |
If you serve different image formats based on the Accept header (e.g., WebP to Chrome, JPEG to Safari via server-side negotiation), you must include Vary: Accept in your response. Without it, a CDN might cache the WebP version and serve it to browsers that don't support WebP.
# When serving format-negotiated images server-side Cache-Control: public, max-age=31536000, immutable Vary: Accept Content-Type: image/webp
Using a <picture> element with separate srcset per format is simpler than server-side Accept negotiation and avoids Vary complexity. Each format lives at its own URL, each cached independently with clean, simple headers.
When a cached image becomes stale (its max-age has passed), the browser doesn't automatically re-download it. Instead, it sends a conditional request to check whether the image has changed. If it hasn't, the server returns a lightweight 304 Not Modified response — no image bytes transferred.
An ETag is a unique identifier for a specific version of a resource. The server sends it with the original response; the browser sends it back in subsequent requests via If-None-Match.
# First request — server responds with ETag HTTP/1.1 200 OK Cache-Control: public, max-age=86400 ETag: "a3f9b2c1d7e4" Content-Type: image/webp # After max-age expires — browser revalidates GET /images/hero.webp HTTP/1.1 If-None-Match: "a3f9b2c1d7e4" # Image unchanged — server returns 304 (no body) HTTP/1.1 304 Not Modified Cache-Control: public, max-age=86400 ETag: "a3f9b2c1d7e4"
Most web servers (Nginx, Apache, Caddy) generate ETags automatically for static files. You generally don't need to configure them manually — but make sure they aren't being stripped by a reverse proxy misconfiguration.
If ETags aren't available, browsers fall back to Last-Modified / If-Modified-Since for revalidation. This is less precise than ETags (timestamp resolution is seconds) but still prevents re-downloading unchanged images.
A Service Worker is a JavaScript file that runs in the background and can intercept network requests — including image requests. This gives you full programmatic control over caching that goes beyond what HTTP headers alone can provide.
Serve from cache if available; fall back to network. Ideal for images that rarely or never change.
const IMAGE_CACHE = 'images-v1'; self.addEventListener('fetch', event => { const { request } = event; // Only handle image requests if (!request.destination === 'image') return; event.respondWith( caches.open(IMAGE_CACHE).then(async cache => { const cached = await cache.match(request); if (cached) return cached; // Cache hit — serve immediately // Cache miss — fetch from network and store const response = await fetch(request); cache.put(request, response.clone()); return response; }) ); });
Serve cached immediately for speed, then fetch fresh in the background. The next request gets the updated version.
event.respondWith( caches.open(IMAGE_CACHE).then(async cache => { const cached = await cache.match(request); // Always fetch in background to refresh cache const networkFetch = fetch(request).then(response => { cache.put(request, response.clone()); return response; }); // Return cached immediately, or wait for network return cached || networkFetch; }) );
Google's Workbox library provides pre-built caching strategies (CacheFirst, StaleWhileRevalidate, NetworkFirst) as one-liners. For most projects, Workbox is easier and more reliable than hand-rolled Service Worker cache logic.
Here are ready-to-use cache header configurations for the most common web servers and platforms.
# Long cache for versioned/hashed assets location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; } # Shorter cache for non-versioned images (e.g. CMS uploads) location /uploads/ { expires 7d; add_header Cache-Control "public, max-age=604800, must-revalidate"; }
<IfModule mod_expires.c> ExpiresActive On ExpiresByType image/webp "access plus 1 year" ExpiresByType image/avif "access plus 1 year" ExpiresByType image/jpeg "access plus 1 year" ExpiresByType image/png "access plus 1 year" ExpiresByType image/gif "access plus 1 year" ExpiresByType image/svg+xml "access plus 1 year" </IfModule> <IfModule mod_headers.c> <FilesMatch "\.(webp|avif|jpe?g|png|gif|svg|ico)$"> Header set Cache-Control "public, max-age=31536000, immutable" </FilesMatch> </IfModule>
In Cloudflare's dashboard under Caching → Cache Rules, create a rule matching http.request.uri.path matches "\.(webp|avif|jpg|jpeg|png|gif|svg)$" with Edge Cache TTL set to 1 year. Enable Browser Cache TTL to respect your origin's Cache-Control header.
{
"headers": [
{
"source": "/(.*)\\.(?:webp|avif|jpg|jpeg|png|gif|svg|ico)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}
Applying a global security header like Cache-Control: no-store to all routes prevents images from being cached at all. Always scope restrictive cache headers to sensitive routes (auth, user data) and use aggressive caching for static assets.
Setting max-age=31536000 on non-versioned URLs means users can be stuck with stale images for up to a year after you update them. Always pair long TTLs with content-hashed filenames or ensure a CDN purge workflow is in place.
Without public in your Cache-Control header, CDNs may refuse to cache the response. By default, responses that include cookies or authorization are treated as private. Always explicitly add public for images served to all users.
Some CDN configurations (and older proxies) cache based on the path only, ignoring query strings. hero.jpg?v=2 may be served from the hero.jpg?v=1 cache. Use filename-based versioning for guaranteed cache invalidation across all caches.
If your server sends WebP to Chrome and JPEG to Safari at the same URL without Vary: Accept, a CDN may cache the WebP and serve it to Safari users. Always set Vary: Accept when doing server-side format negotiation — or use separate URLs per format via <picture>.
Always verify with browser DevTools (Network tab → select image → Response Headers) or curl -I https://yoursite.com/image.webp. It's common for CDNs, reverse proxies, or frameworks to strip or override cache headers set at the origin.
curl -I on actual image URLs.max-age=31536000, immutable. Build tool configured to append content hashes to image filenames.must-revalidate. CMS upload paths, profile images, etc.no-store or missing cache headers on image routes. Checked that global security headers don't apply to static asset paths.public directive is set on all images served to anonymous users so CDNs can cache them.Vary: Accept — or format switching is handled via <picture> with separate URLs per format.200 first load and 304 or from disk cache on repeat.Images at content-hashed URLs (e.g. hero.a3f9b2.webp) should be cached for 1 year — max-age=31536000. Since the URL changes whenever the image changes, there's no risk of serving stale content. For images at static, non-versioned URLs (CMS uploads, profile photos), use 1–7 days with must-revalidate to balance freshness and performance.
Cache-busting is the practice of changing an image's URL whenever its content changes, forcing browsers and CDNs to fetch the new version. The most reliable method is content hashing: appending a hash of the file's contents to the filename (e.g. hero.a3f9b2.webp). When the image changes, the hash changes, the URL changes, and every cache treats it as a new resource.
Yes, significantly. Properly cached images improve page load speed on repeat visits, which directly improves LCP — a Core Web Vital and Google ranking signal. Google's field data (Chrome User Experience Report) captures repeat visits, so caching improves your actual ranking score. Additionally, faster repeat load times reduce bounce rates, which correlates with better organic performance.
For versioned/hashed URLs: Cache-Control: public, max-age=31536000, immutable. The immutable directive prevents browsers from revalidating during the max-age window, saving unnecessary round trips. For non-versioned URLs: Cache-Control: public, max-age=86400, must-revalidate. Never use no-store on images — it forces a full re-download on every single page load.
Browser cache stores images on the user's local device — repeat page loads serve them with zero network time. CDN cache stores images on edge servers close to users — even on first visit, the image travels a short distance from a nearby CDN node rather than your origin server. Both are controlled by Cache-Control headers and both are important. They work together: CDN reduces latency on first visit; browser cache eliminates network latency on repeat visits.
Yes. A Service Worker intercepts image fetch requests and can serve them from the Cache Storage API, enabling offline support and custom per-image caching strategies beyond what HTTP headers alone allow. Common strategies are Cache-First (always serve from cache if available) and Stale-While-Revalidate (serve cached immediately, refresh in background). For most projects, Google's Workbox library makes Service Worker image caching straightforward to implement.
In Chrome DevTools: open the Network tab, make sure "Disable cache" is unchecked, reload the page, click an image request, and inspect the Response Headers for Cache-Control, ETag, and Last-Modified. On a second load, the Status column should show 304 (revalidated) or 200 (from disk cache) / 200 (from memory cache). You can also run curl -I https://yoursite.com/image.webp to inspect headers directly.
More resources to help you optimize images for performance and Core Web Vitals.