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

HTTP caching headers tell browsers and CDNs how long to cache a response, when to revalidate, and whether the response can be served from a shared cache. Properly configured headers reduce latency, lower origin load, and improve Core Web Vitals. The solution below covers Cache-Control, ETag, Last-Modified, and stale-while-revalidate for both API responses and static assets.

When to Use This

Prerequisites

  • A web server or framework that lets you set response headers
  • Basic understanding of HTTP request/response cycle

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);
});

The client sends If-None-Match: "<etag>" on subsequent requests. If the ETag matches, the server returns 304 Not Modified with no body — the client uses its cached copy.

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).toUTCString();

  if (req.headers["if-modified-since"] === lastModified) {
    return res.status(304).end();
  }

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

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);
});

The CDN serves the cached response for 60 seconds. Between 60-360 seconds, it serves the stale response while fetching a fresh copy in the background.

6. Python / FastAPI Example

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

app = FastAPI()

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

@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,
        },
    )

How It Works

  1. max-age — the number of seconds the response is considered fresh. The browser serves from cache without revalidation during this period.
  2. public — allows shared caches (CDNs, proxies) to store the response. Use private for user-specific data.
  3. immutable — tells the browser the response will never change during its freshness lifetime, skipping conditional revalidation entirely.
  4. ETag — a content fingerprint. The client sends If-None-Match on subsequent requests; a match returns 304 Not Modified.
  5. stale-while-revalidate — after max-age expires, the CDN serves stale content while fetching a fresh copy asynchronously, eliminating latency for the user.

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

  • For a deeper guide, see Complete Guide to GraphQL Caching.

  • Hash filenames for static assets — enables immutable caching with max-age=31536000

  • Use no-store for sensitive data — billing, auth tokens, personal information

  • 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

Common Mistakes

  • Caching user-specific responses with public — leaks data between users through the CDN
  • Setting max-age=0 without no-cachemax-age=0 forces revalidation but still stores the response; no-store prevents storage
  • Forgetting Vary: Accept-Encoding — a gzipped response cached for a client that doesn’t support gzip causes errors
  • Using Expires instead of Cache-ControlExpires is HTTP/1.0 and less flexible; prefer Cache-Control

Troubleshooting

  • Cache and database are out of sync: define a TTL or invalidation policy.
  • Hit rate dropped after a deployment: check cache key generation and serialization changes. A new version may use different keys.
  • Cold cache causes thundering herd: use cache warming, request coalescing, or single-flight patterns for hot keys.
  • Memory usage grows uncontrollably: set max memory policies, eviction thresholds, and key expiration. Audit large values.
  • Stale data served to users: implement cache invalidation on write and cache-bust URLs for static assets.

Quick Reference

  • Main command: run the base solution from the article and verify the expected result.
  • Validation: confirm tests pass and key metrics did not degrade.
  • Rollback: if something fails, revert the change and consult the Troubleshooting section.

Further Reading

  • Official documentation: check the current reference for the framework or tool used.
  • Related guides: explore the http and caching guides for deeper coverage.
  • Complementary patterns: review design patterns applicable to your technology stack.
  • Public postmortems: study real incidents from teams that faced similar production issues.

Production Notes

  • Deploy gradually using canary or blue-green to catch regressions early.
  • Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
  • Document the rollback in the runbook; test the procedure in staging at least once per quarter.
  • Review structured logs with correlation IDs to trace requests end-to-end during incidents.

Key Takeaways

  • Apply http cache-control headers for apis and static assets when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Common Production Pitfalls

  • Copying the example without adapting it to real data volumes and failure modes.
  • Skipping load and error-injection tests before the first production deployment.
  • Hard-coding values that should be configurable per environment.
  • Forgetting to add logging and monitoring at each step.
  • Deploying without a rollback plan or a tested backup strategy.
  • Assuming the minimal example will scale without adding caching or batching.
  • Not documenting the version and configuration used in production.
  • Letting the recipe sit unchanged when dependencies or scale evolve.

Frequently Asked Questions

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

no-cache stores the response but requires revalidation before use. no-store prevents storage entirely. Use no-store for sensitive data.

Should I use ETag or Last-Modified?

ETag is more precise (content hash vs. timestamp). Use both — clients that support ETag use it; others fall back to Last-Modified.

How long should I cache static assets?

One year (max-age=31536000) with immutable if filenames are content-hashed. Otherwise, use a shorter TTL with revalidation.

Does stale-while-revalidate work in browsers?

It works in Chrome and Firefox. Safari ignores it. CDNs like Cloudflare and Fastly support it regardless of browser.