StackPractices
beginner By Mathias Paulenko

HTTP Cache-Control Headers for APIs and Static Assets

Set Cache-Control, ETag, and Last-Modified headers to control browser and CDN caching for API responses and static assets

Overview

HTTP caching headers tell browsers and CDNs how long to cache a response, when to revalidate, and whether a shared cache may store it at all. A well-set Cache-Control cuts latency, lowers origin load, and improves Core Web Vitals; a missing or wrong one either leaks private data through shared caches or defeats caching entirely. This recipe covers Cache-Control, ETag, Last-Modified, and stale-while-revalidate for API responses and static assets, with working examples for nginx, Express, and FastAPI.

When to Use

  • Serving static assets (JS, CSS, images, fonts) with content hashes in the filename — they can be cached for a year with immutable.
  • Public API responses that are identical for every user or change at predictable intervals.
  • SSR pages behind a CDN — s-maxage is how you give the edge a longer freshness window than browsers get.
  • Large or expensive responses where conditional requests (ETag, Last-Modified) save bandwidth by returning 304 Not Modified.
  • For server-side object caching rather than HTTP caching, see Node.js Caching with Redis: Cache-Aside and TTL Patterns.

When NOT to Use

  • Authenticated or personalized responses — never mark them public; use private, no-cache or no-store so a shared cache can’t serve one user’s data to another.
  • Responses that change on every request — real-time feeds, CSRF tokens, nonces. For those, no-store is the only honest answer; anything else is a promise some cache may act on.
  • HTML entry points that must reflect every deploy — prefer no-cache with ETag over a long max-age, or users will hold a stale shell that references deleted hashed assets.

Prerequisites

  • A server or framework where you control response headers — the examples use nginx, Express, and FastAPI.
  • You can read request and response headers; nothing fancier is needed.

Solution

1. Static Assets — Long Cache with Immutable

Static assets with content hashes in filenames can be cached aggressively:

# nginx.conf
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}
// Express.js
app.use(express.static("public", {
  maxAge: "1y",
  setHeaders: (res, path) => {
    if (path.endsWith(".js") || path.endsWith(".css")) {
      res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
    }
  },
}));

The immutable flag tells the browser to never revalidate — the filename changes when the content changes (e.g., app.abc123.js).

2. API Responses — Short Cache with Revalidation

// Express.js — cache API responses for 60 seconds with revalidation
app.get("/api/products", async (req, res) => {
  const products = await getProducts();

  res.setHeader("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
  res.json(products);
});

// No caching for user-specific data
app.get("/api/users/me", authMiddleware, async (req, res) => {
  const user = await getUser(req.userId);

  res.setHeader("Cache-Control", "private, no-cache");
  res.json(user);
});

3. ETag for Conditional Requests

import crypto from "crypto";

app.get("/api/products", async (req, res) => {
  const products = await getProducts();
  const etag = `"${crypto.createHash("sha256").update(JSON.stringify(products)).digest("hex").slice(0, 16)}"`;

  if (req.headers["if-none-match"] === etag) {
    return res.status(304).end();
  }

  res.setHeader("ETag", etag);
  res.setHeader("Cache-Control", "public, max-age=60");
  res.json(products);
});

On later requests the client echoes that fingerprint back as If-None-Match: "<etag>". When the ETag matches, the server answers with a bare 304 Not Modified — no body, no re-serialization — and the client keeps its cached copy. For this to work, the hashed payload has to be stable: a timestamp or a request-scoped field sneaking into the serialization produces a different ETag on every request, and the conditional-fetch optimization dies.

4. Last-Modified for Conditional Requests

app.get("/api/articles/:id", async (req, res) => {
  const article = await getArticle(req.params.id);
  const lastModified = new Date(article.updatedAt);

  const ifModifiedSince = req.headers["if-modified-since"];
  if (ifModifiedSince && new Date(ifModifiedSince) >= lastModified) {
    return res.status(304).end();
  }

  res.setHeader("Last-Modified", lastModified.toUTCString());
  res.setHeader("Cache-Control", "public, max-age=300");
  res.json(article);
});

If-Modified-Since is a threshold, not an exact match — compare parsed dates, because a client or intermediate cache can legitimately send a later timestamp. When both validators exist, ETag/If-None-Match wins and Last-Modified is ignored.

5. stale-while-revalidate for Background Refresh

app.get("/api/trending", async (req, res) => {
  const data = await getTrending();

  // Cache for 60s, then serve stale for up to 300s while revalidating
  res.setHeader(
    "Cache-Control",
    "public, max-age=60, stale-while-revalidate=300"
  );
  res.json(data);
});

During the first 60 seconds the CDN answers straight from cache. After that it keeps serving the same copy for up to five more minutes, while it quietly fetches a fresh one in the background — so no visitor ever waits on the origin round-trip.

6. Python / FastAPI Example

StaticFiles doesn’t accept a max_age argument, so mount it normally and add the header with a middleware:

from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
import hashlib
import json

app = FastAPI()

app.mount("/static", StaticFiles(directory="public"), name="static")

@app.middleware("http")
async def cache_headers(request: Request, call_next):
    response = await call_next(request)
    if request.url.path.startswith("/static/"):
        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
    return response

@app.get("/api/products")
async def get_products(request: Request):
    products = await fetch_products()
    body = json.dumps(products, default=str)
    etag = f'"{hashlib.sha256(body.encode()).hexdigest()[:16]}"'

    if request.headers.get("if-none-match") == etag:
        return Response(status_code=304)

    return Response(
        content=body,
        media_type="application/json",
        headers={
            "Cache-Control": "public, max-age=60, stale-while-revalidate=300",
            "ETag": etag,
        },
    )

The runnable versions of these configs — the full nginx.conf, the Express app, and the FastAPI app — live in the companion repository.

How It Works

flowchart diagram: Client request

The right combination depends on what you’re serving:

Directive combinationApplies toWhat it does
public, max-age=31536000, immutableHashed static assetsYear-long cache everywhere; never revalidates
public, max-age=60, stale-while-revalidate=300Public API readsFresh 60s, then stale up to 300s while refetching
s-maxage=3600, max-age=60SSR pages behind a CDNEdge caches 1 hour; browser revalidates every minute
private, no-cacheAuthenticated dataBrowser may store it but must revalidate; shared caches skip it
no-storeBilling, tokens, personal dataNothing is stored by any cache
  1. max-age — the number of seconds the response is considered fresh. The browser serves from cache without revalidation during this period.
  2. public / private — public opens the response to shared caches (CDNs, proxies); private limits storage to the end user’s browser.
  3. immutable — tells the browser the response will never change during its freshness lifetime, skipping conditional revalidation entirely.
  4. ETag — a fingerprint the server computes from the response payload. The client echoes it in If-None-Match; on a match, the server replies 304 Not Modified.
  5. stale-while-revalidate — once max-age lapses, the CDN keeps answering with the stale copy while it refetches asynchronously, so nobody pays the latency of a synchronous refresh.

Variants

No-Store for Sensitive Data

app.get("/api/user/billing", authMiddleware, async (req, res) => {
  res.setHeader("Cache-Control", "no-store");
  res.json(billingData);
});

no-store prevents any cache — browser, CDN, or proxy — from storing the response.

Vary Header for Content Negotiation

app.get("/api/products", (req, res) => {
  res.setHeader("Vary", "Accept-Encoding, Accept-Language");
  res.setHeader("Cache-Control", "public, max-age=300");
  // Response varies by encoding (gzip, br) and language
  res.json(products);
});

Surrogate-Control for CDN-Specific Caching

res.setHeader("Surrogate-Control", "max-age=3600");
res.setHeader("Cache-Control", "max-age=60");

CDNs use the longer Surrogate-Control TTL, while browsers use the shorter Cache-Control TTL.

Best Practices

  • Hash filenames for static assets — that’s what makes a year-long immutable policy safe: app.a1b2c3.js changes name whenever its bytes change.
  • Use no-store for sensitive data — billing info, auth tokens, anything personal.
  • Set Vary correctly — omitting Accept-Encoding causes compressed and uncompressed responses to collide in cache.
  • Use stale-while-revalidate for APIs — eliminates user-facing latency during revalidation.
  • For GraphQL-specific cache strategies, see the Complete Guide to GraphQL Caching.

Common Mistakes

  • Caching user-specific responses with public — leaks data between users through the CDN.
  • Setting max-age=0 when you mean no-store — a response with max-age=0 still gets stored and only forces revalidation; only no-store keeps it out of every cache.
  • Forgetting Vary: Accept-Encoding — a gzipped response cached for a client that doesn’t support gzip causes errors.
  • Relying on Expires instead of Cache-Control — the HTTP/1.0 date header breaks with wrong client clocks; Cache-Control covers everything it does and more.

Troubleshooting

  • Browser never caches despite max-age: the request carries Authorization or the response sets Set-Cookie — both make browsers and most CDNs refuse to store unless public or s-maxage explicitly allows it. Check the “Disk cache” column in DevTools Network.
  • 304 Not Modified never happens: the ETag changes on every request because the hashed payload includes unstable fields (timestamps, request IDs). Hash only the data that defines the response.
  • CDN keeps serving stale content after a deploy: origin max-age was too long and there’s no s-maxage/Surrogate-Control split, or the CDN was never purged. Shorten the shared TTL or purge by cache tag.
  • Safari still waits for revalidation: it ignores stale-while-revalidate — treat the directive as a latency optimization for browsers and CDNs that support it, never as a correctness guarantee.
  • Client gets a gzip body it can’t decode: Vary: Accept-Encoding is missing, so a compressed response cached once gets replayed to every client.
  • One user sees another user’s data: a response was marked public without Vary: Authorization, and a shared cache stored it. Switch to private, no-cache and audit which routes set public.

Further Reading

Frequently Asked Questions

What is the difference between no-cache and no-store?

no-cache stores the response but requires revalidation before reuse; no-store prevents storage entirely. Reach for no-store whenever the response carries sensitive data like tokens or billing.

Should I use ETag or Last-Modified?

ETag is more precise — a content hash beats a one-second-resolution timestamp. Send both: clients that support If-None-Match use the ETag, and older clients fall back to If-Modified-Since.

How long should I cache static assets?

One year (max-age=31536000) with immutable, but only if filenames carry a content hash. Without hashed filenames, use a shorter TTL with revalidation or deploys will serve stale files.

Does stale-while-revalidate work in browsers?

Chrome and Firefox honor it; Safari doesn't. CDNs such as Cloudflare or Fastly apply it no matter what the browser does.

What is the difference between max-age and s-maxage?

max-age applies to every cache; s-maxage applies only to shared caches (CDNs, proxies) and overrides max-age for them. s-maxage=600, max-age=60 lets a CDN cache for 10 minutes while browsers revalidate every minute.

How do I invalidate cached responses?

For the browser, no-cache plus ETag forces revalidation and a new ETag forces a fresh fetch. For a CDN, use its purge API — tag-based purging invalidates groups of URLs by cache tag. See CDN Cache Invalidation Strategies for surrogate keys and purge patterns.

How do I cache authenticated requests?

Set Cache-Control: private so shared caches never store user-specific responses, and Vary: Authorization if the response differs by credentials. Never put responses containing tokens or session IDs in a shared cache.

How do I implement cache busting for hashed assets?

Embed the content hash in the filename (app.a1b2c3d4.js) and hand it out with public, max-age=31536000, immutable. Change a byte and the hash — and therefore the URL — changes, so the browser fetches it as something new. Keep the HTML that references it on no-cache so browsers always pick up the new filenames.

How do I debug caching issues?

Start with curl -I on the URL and read what comes back: Cache-Control is the policy, Age is how many seconds a shared cache has held the copy (above max-age it's stale), and X-Cache reports hit vs. miss when a CDN is in front. DevTools' Network tab shows the same info, and a request sent with Cache-Control: no-cache forces revalidation end to end.

How do Vary headers affect caching?

Vary lists the request headers that change the response: Vary: Accept-Encoding keeps gzip and brotli copies separate, Vary: Accept keeps JSON and HTML separate, Vary: Origin separates CORS responses per origin. Never use Vary: * — it disables caching — and avoid Vary: User-Agent, which fragments the cache into thousands of keys.