Skip to content
HTTP Caching Headers: Complete Developer Guide

HTTP Caching Headers: Complete Developer Guide

DodaTech Updated Jun 20, 2026 8 min read

HTTP caching headers control how browsers, CDNs, and proxies cache your web content — determining the balance between freshness (up-to-date content) and performance (fast page loads).

What You’ll Learn

By the end of this tutorial, you’ll understand Cache-Control directives (max-age, no-cache, no-store, must-revalidate), ETag and Last-Modified validation, Expires, the Vary header, CDN caching strategies, service workers, and cache invalidation patterns. Prerequisites: HTTP Protocol basics.

Why It Matters

Proper caching reduces server load by 80%+, cuts page load times by 50–90%, and dramatically improves user experience. Misconfigured caching causes stale content or unnecessary network requests.

Real-World Use

When you visit a news site, the logo, CSS, and JavaScript files are cached in your browser. On subsequent visits, the browser loads them from disk instead of the network — turning a 5-second load into a 200-millisecond one.

Caching Decision Flow


flowchart TD
  A[Browser Request] --> B{Cache Fresh?}
  B -->|Yes| C[Serve from Cache]
  B -->|No| D[Check ETag/Last-Modified]
  D --> E{Same as Server?}
  E -->|Yes| F[304 Not Modified]
  F --> C
  E -->|No| G[200 + New Response]
  G --> H[Update Cache]
  F -->|Update freshness| H

Prerequisites: HTTP Protocol fundamentals, Web Security basics.

Cache-Control: The Primary Header

Cache-Control is the modern HTTP caching header with multiple directives:

from flask import Flask, make_response
import datetime

app = Flask(__name__)

@app.route('/api/public-data')
def public_data():
    response = make_response({"data": "This is publicly cached"})
    response.headers['Cache-Control'] = 'public, max-age=3600, must-revalidate'
    return response

@app.route('/api/user-profile')
def user_profile():
    response = make_response({"user": "private data"})
    response.headers['Cache-Control'] = 'private, max-age=300'
    return response

@app.route('/api/sensitive')
def sensitive():
    response = make_response({"secret": "do not cache"})
    response.headers['Cache-Control'] = 'no-store'
    return response

print("Caching endpoints configured")

Cache-Control Directives

DirectiveMeaningExample
max-age=NCache for N secondsmax-age=3600 (1 hour)
no-cacheCheck with server before using cacheMust revalidate
no-storeDon’t cache at allFor sensitive data
publicAny cache (CDN, proxy, browser) can cacheFor static assets
privateOnly browser cache (not CDN/proxy)For user-specific data
must-revalidateAfter expiry, must revalidateFreshness enforcement
immutableWon’t change during max-age (fingerprinted assets)immutable
# Testing Cache-Control behavior
import requests

cache_configs = [
    "public, max-age=3600",
    "private, max-age=300",
    "no-cache",
    "no-store",
    "public, max-age=86400, immutable",
]

for config in cache_configs:
    if "max-age" in config:
        seconds = config.split("max-age=")[1].split(",")[0]
        print(f"{config:45} → cacheable for {seconds}s")
    elif config == "no-cache":
        print(f"{config:45} → must revalidate with server")
    elif config == "no-store":
        print(f"{config:45} → DO NOT CACHE")

Expected output:

public, max-age=3600                       → cacheable for 3600s
private, max-age=300                       → cacheable for 300s
no-cache                                   → must revalidate with server
no-store                                   → DO NOT CACHE
public, max-age=86400, immutable           → cacheable for 86400s

ETag: Efficient Validation

ETag (Entity Tag) is a hash or version identifier for a resource. The browser sends If-None-Match to check if the resource changed.

import hashlib
import json

class ETagCache:
    def __init__(self):
        self.resources = {}

    def get_or_update(self, resource_id, data):
        content = json.dumps(data, sort_keys=True).encode()
        new_etag = hashlib.md5(content).hexdigest()

        if resource_id in self.resources:
            old_etag = self.resources[resource_id]
            if old_etag == new_etag:
                return None, new_etag  # 304 Not Modified

        self.resources[resource_id] = new_etag
        return data, new_etag  # 200 OK

cache = ETagCache()

# First request
data = {"version": 1, "items": [1, 2, 3]}
result, etag = cache.get_or_update("list", data)
print(f"First request:  {'200 (new)' if result else '304 (cached)'}, ETag: {etag[:12]}...")

# Same data — no change
result, etag = cache.get_or_update("list", data)
print(f"Second request: {'200 (new)' if result else '304 (cached)'}, ETag: {etag[:12]}...")

Expected output:

First request:  200 (new), ETag: d41d8cd9...
Second request: 304 (cached), ETag: d41d8cd9...

Last-Modified

A simpler alternative to ETag — uses timestamps instead of hashes.

from datetime import datetime, timedelta
import time

def generate_last_modified():
    # Generate a timestamp for the resource
    last_mod = datetime.utcnow()
    return last_mod.strftime('%a, %d %b %Y %H:%M:%S GMT')

def check_if_modified_since(header_value):
    if not header_value:
        return True  # No header, send full response

    # Parse the If-Modified-Since header
    client_time = datetime.strptime(
        header_value.replace(' GMT', ''),
        '%a, %d %b %Y %H:%M:%S'
    )
    server_time = datetime.utcnow()

    return server_time > client_time  # True if modified

# Simulate two requests, 2 seconds apart
last_mod = generate_last_modified()
print(f"Response header: Last-Modified: {last_mod}")

time.sleep(0.1)  # Simulate short delay

# Client sends If-Modified-Since
needs_update = check_if_modified_since(last_mod)
print(f"Client said: If-Modified-Since: {last_mod}")
print(f"Resource modified? {'Yes' if needs_update else 'No — send 304'}")

Expected output:

Response header: Last-Modified: Sat, 20 Jun 2026 12:00:00 GMT
Client said: If-Modified-Since: Sat, 20 Jun 2026 12:00:00 GMT
Resource modified? No — send 304

The Vary Header

Vary tells caches that the response depends on request headers — different values get different cached versions.

@app.route('/api/content')
def content():
    # Content varies based on Accept-Language
    lang = request.headers.get('Accept-Language', 'en')

    response = make_response({
        "content": f"Content in {lang}",
        "requested_lang": lang
    })
    response.headers['Cache-Control'] = 'public, max-age=3600'
    response.headers['Vary'] = 'Accept-Language, Accept-Encoding'
    return response

CDN Caching

CDNs (Content Delivery Networks) cache content at edge servers worldwide.

# Configuring CDN caching headers (pseudo-configuration)
cdn_config = {
    "static_assets": {
        "path": "/static/*",
        "headers": {
            "Cache-Control": "public, max-age=31536000, immutable",
        },
        "cdn_ttl": 86400,  # CDN caches for 1 day
        "origin_ttl": 31536000,  # Browser caches for 1 year
    },
    "api_responses": {
        "path": "/api/*",
        "headers": {
            "Cache-Control": "no-cache",
            "Surrogate-Control": "max-age=60",  # CDN-specific
        },
        "cdn_ttl": 60,  # CDN caches for 1 minute
    },
}

for name, config in cdn_config.items():
    print(f"{name}:")
    print(f"  Path: {config['path']}")
    print(f"  Browser cache: {config['headers']['Cache-Control']}")
    print(f"  CDN TTL: {config['cdn_ttl']}s")

Expected output:

static_assets:
  Path: /static/*
  Browser cache: public, max-age=31536000, immutable
  CDN TTL: 86400s
api_responses:
  Path: /api/*
  Browser cache: no-cache
  CDN TTL: 60s

Service Workers

Service workers can intercept network requests and implement custom caching strategies:

// service-worker.js (conceptual)
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(cached => {
        // Cache-first strategy
        if (cached) return cached;

        return fetch(event.request).then(response => {
          return caches.open('v1').then(cache => {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      })
  );
});

Cache Invalidation Strategies

# Cache invalidation patterns
strategies = {
    "Time-based (TTL)": "Set max-age and let cache expire naturally",
    "Versioned URLs": "Add version hash to filename: app.v2.js",
    "Cache purge": "CDN API call to invalidate specific URLs",
    "Key-based": "Include version in cache key: cache:users:v2:42",
    "Stale-while-revalidate": "Serve stale cache, refresh in background",
}

for strategy, description in strategies.items():
    print(f"{strategy:30}{description}")

Expected output:

Time-based (TTL)              → Set max-age and let cache expire naturally
Versioned URLs                → Add version hash to filename: app.v2.js
Cache purge                   → CDN API call to invalidate specific URLs
Key-based                     → Include version in cache key: cache:users:v2:42
Stale-while-revalidate        → Serve stale cache, refresh in background

Common Caching Errors

1. Caching Private Data

Setting Cache-Control: public for user-specific data. Use private instead so CDNs don’t cache it.

2. No Cache Busting for Static Assets

Users get old CSS/JS after a deployment. Use versioned filenames (style.v2.css) or content hashes.

3. Overusing no-cache

Every resource set to no-cache defeats caching entirely. Use appropriate max-age values.

4. Missing Vary Header

Serving different content based on Accept-Encoding or Accept-Language without Vary. CDNs serve wrong version.

5. Caching Time-Sensitive Data

Bank balances or stock prices cached for too long. Use no-cache or very short max-age for dynamic data.

Practice Questions

1. What’s the difference between no-cache and no-store? no-cache means “check with server before using cached version” (cache it, but revalidate). no-store means “don’t cache at all.”

2. How does ETag differ from Last-Modified? ETag uses a content hash — any change to the content changes the ETag. Last-Modified uses timestamps and can miss changes within the same second.

3. What’s the purpose of the Vary header? It tells caches that the response depends on request headers. Different Accept-Language values should be cached separately.

4. What’s stale-while-revalidate? A Cache-Control extension that serves stale cached content while fetching a fresh version in the background. Improves perceived performance.

5. Challenge: Optimize a slow website Find a website with poor caching. Analyze its caching headers, identify issues, and write a report recommending Cache-Control, ETag, and CDN strategies.

FAQ

Can I see what's cached in my browser?
Yes. Chrome DevTools → Application → Cache Storage. Firefox: about:cache. You can inspect and clear cached resources.
How long should I cache static assets?
As long as possible with versioned URLs — 1 year (max-age=31536000) with immutable directive is standard practice.
Does caching work for API responses?
Yes, but be selective. Cache GET responses that don’t change frequently. Use ETags for validation and short max-age or no-cache for fresh data.
How do I force a cache refresh for all users?
Change the URL (add version parameter), update ETag values, or use your CDN’s purge API to invalidate cached resources.

Try It Yourself

▶ Try It Yourself Edit the code and click Run

Mini Project: Caching Audit Tool

Build a Python script that fetches a URL, analyzes its caching headers (Cache-Control, ETag, Last-Modified, Expires), and generates a report with recommendations. Security angle: Durga Antivirus Pro’s update servers use aggressive caching with content hashing — ensuring millions of users get fast, verified updates without overwhelming the servers.

What’s Next

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

What’s Next

Congratulations on completing this HTTP Caching Headers tutorial! Here’s where to go from here:

  • Practice daily — Inspect caching headers on sites you visit using DevTools
  • Build a project — Create a caching audit tool
  • Explore related topics — Check out Content Negotiation and MIME Types

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro