⚡ Performance Guide

Image Caching Guide: Faster Pages on Every Repeat Visit

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.

By IMGVO Updated June 2026 12 min read
~80% of page weight is images on a typical website
0ms network time for a properly cached image on repeat visit
1 yr recommended max-age for versioned image URLs

1. How Image Caching Works

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.

The Cache Decision Flow

When a browser needs an image, it follows this decision tree:

  1. Is it in the browser cache and still fresh? Serve immediately. No network request.
  2. Is it in the browser cache but stale? Send a conditional request (with If-None-Match or If-Modified-Since) to revalidate. If unchanged, server returns 304 Not Modified — still no re-download.
  3. Not in cache? Full request to CDN or origin. CDN may have it cached — if so, it responds without hitting your origin. Otherwise, origin serves it and CDN stores it for future requests.
ℹ️ Why This Matters for Core Web Vitals

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.


2. Cache-Control Headers for Images

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.

The Two Patterns You'll Actually Use

For images, there are really only two patterns worth knowing:

URL TypeRecommended HeaderTTL
Versioned / hashed (e.g. hero.a3f9b2.webp)public, max-age=31536000, immutable1 year
Non-versioned (e.g. logo.png)public, max-age=86400, must-revalidate1 day
User-specific / privateprivate, max-age=36001 hour

Key Directives Explained

✓ Best Practice — Versioned 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.

✗ Avoid — no-store on Images

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.


3. Cache-Busting Strategies

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.

Method 1: Content Hashing (Recommended)

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.

File naming
# 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.

Method 2: Version Query String

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.

HTML
<!-- Query string versioning (less reliable with some CDNs) -->
<img src="/images/hero.webp?v=2" alt="Hero" />

Method 3: CDN Cache Purge

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 / Next.js Users

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.


4. CDN Edge Caching

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.

How CDNs Cache Images

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.

CDNDefault BehaviorOverride Method
CloudflareRespects origin Cache-ControlCache Rules in dashboard
AWS CloudFrontRespects origin Cache-ControlCache Policies per behavior
FastlyRespects origin Cache-ControlVCL or Compute@Edge
Bunny.netRespects origin Cache-ControlPer-zone cache TTL setting

Vary Header and CDN Caching

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.

HTTP Response Headers
# When serving format-negotiated images server-side
Cache-Control: public, max-age=31536000, immutable
Vary: Accept
Content-Type: image/webp
ℹ️ Prefer <picture> Over Server Negotiation

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.


5. ETags and Conditional Requests

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.

ETag (Entity Tag)

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.

HTTP Exchange
# 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.

Last-Modified as Fallback

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.


6. Service Worker Image Caching

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.

When to Use Service Worker Caching for Images

Cache-First Strategy (Best for Static Images)

Serve from cache if available; fall back to network. Ideal for images that rarely or never change.

JavaScript (Service Worker)
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;
    })
  );
});

Stale-While-Revalidate (Best for Frequently Updated Images)

Serve cached immediately for speed, then fetch fresh in the background. The next request gets the updated version.

JavaScript (Service Worker)
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;
  })
);
💡 Use Workbox

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.


7. Server Configuration Examples

Here are ready-to-use cache header configurations for the most common web servers and platforms.

Nginx

Nginx Config
# 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";
}

Apache (.htaccess)

.htaccess
<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>

Cloudflare (Cache Rules)

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.

Vercel / Next.js

vercel.json
{
  "headers": [
    {
      "source": "/(.*)\\.(?:webp|avif|jpg|jpeg|png|gif|svg|ico)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ]
}

8. Common Image Caching Mistakes


9. Full Image Caching Checklist


10. Frequently Asked Questions

Optimize Your Images Before Caching Them

Smaller images cache faster and load faster on first visit too. Convert to WebP or AVIF free — no upload, no account needed.

Convert Images Free →

Related Guides

More resources to help you optimize images for performance and Core Web Vitals.