HTTP Caching for APIs: Cache-Control, Vary, and Surrogate Keys Explained

Your API is slow. Your database is getting hammered. You’ve profiled, indexed, tuned connection pools — and yet under real load, response times sag. Nine times out of ten, the fix isn’t more hardware. It’s proper HTTP caching.

The problem is that most developers treat caching as a black box: slap a CDN in front, set some vague max-age, and call it done. That works until it doesn’t — stale data served to the wrong clients, cache poisoning, or a complete inability to invalidate anything without a full purge.

This article covers the actual mechanics. How Cache-Control directives compose, why Vary is both essential and dangerous, and how surrogate keys (cache tags) give you surgical invalidation at scale. By the end you’ll have a mental model that covers browser caches, reverse proxies like Nginx and Varnish, and CDNs like Cloudflare, Fastly, and AWS CloudFront.


Why API Caching Is Different From Page Caching

Caching HTML pages is relatively straightforward: a URL maps to content, content changes infrequently, and you bust the cache by deploying with new asset fingerprints. APIs are messier.

The same endpoint can return different data depending on: the authenticated user, the Accept header (JSON vs Protobuf), the Accept-Language header, query parameters, feature flags in a cookie, or a request-scoped tenant ID. You can’t just cache /api/products as a single object. You need to cache variants of it, label those variants so you can find them later, and invalidate them precisely when the underlying data changes.

The HTTP spec gives you the tools. Most people just haven’t read RFC 9111.


Cache-Control: The Real API

Cache-Control is a request and response header. Most tutorials only cover the response side, which is why people get confused when a browser ignores their server’s directives.

Response directives — what your server says

max-age=N — The response is fresh for N seconds from the time it was generated. This is relative time, not wall-clock. A proxy or browser can serve this from cache without hitting your origin for N seconds.

s-maxage=N — Same as max-age but only applies to shared caches (CDNs, reverse proxies). Browsers ignore it. Use this to set a longer TTL on your CDN while keeping the browser’s copy shorter:

Cache-Control: max-age=60, s-maxage=3600

This tells the browser: re-validate after 60 seconds. Tells Fastly/Cloudflare: you can serve this for an hour.

no-cache — Counterintuitive name. This does not mean "don’t cache". It means "cache it, but always revalidate with the origin before serving". With ETag or Last-Modified this is cheap — the origin returns 304 Not Modified if nothing changed, and the proxy serves the cached body. Bandwidth saved, freshness guaranteed.

no-store — This one actually means "don’t cache". Nothing gets written to disk or memory. Use this for responses containing payment details, session tokens, or anything you genuinely cannot afford to serve stale.

private — The response is user-specific. Shared caches (CDNs, Varnish) must not cache it. The browser can. Use this on /api/me, /api/cart, anything authenticated and personal.

public — Explicitly tells shared caches they’re allowed to cache this response, even if it came over an authenticated connection. By default, CDNs won’t cache responses to authenticated requests — public overrides that. Only use it when you’re sure the response isn’t user-specific.

stale-while-revalidate=N — Serve the stale cached response immediately, revalidate in the background. Keeps latency low during revalidation. This is underused. Combine it with s-maxage:

Cache-Control: s-maxage=300, stale-while-revalidate=60

The CDN serves instantly for up to 360 seconds total — 300 fresh, 60 stale-while-revalidating — and your origin never blocks a user request during a cache miss.

stale-if-error=N — If the origin returns a 5xx, keep serving the stale cached response for N more seconds. Free resilience against origin failures.

Request directives — what clients send

These matter when you’re debugging. Browsers send Cache-Control: no-cache when you force-refresh (Ctrl+Shift+R). A well-configured proxy should respect no-cache from clients and bypass its cache for that request. Some CDNs let you configure whether to honor client cache directives — usually you want to ignore them in production or you’ll get cache-busting for every impatient user.

max-stale=N lets a client say "I’ll accept a response up to N seconds stale." Useful in offline-capable mobile apps. Almost never relevant for server-side API consumers.


Conditional Requests: ETag and Last-Modified

These work with Cache-Control: no-cache and on their own. The flow:

  1. Origin responds with ETag: "abc123" or Last-Modified: Sat, 24 May 2026 10:00:00 GMT.
  2. Cache stores the response plus the validator.
  3. Next request for the same resource: the cache sends If-None-Match: "abc123" or If-Modified-Since: ....
  4. Origin checks: unchanged → 304 Not Modified (no body). Changed → 200 OK with a new body and new validator.

For APIs, ETag based on a content hash or version counter is cleaner than Last-Modified. Timestamps have second-level granularity and are fragile under clock skew.

# Nginx: enable ETag for proxy cache
location /api/ {
    proxy_pass http://upstream;
    proxy_cache api_cache;
    proxy_cache_valid 200 5m;
    # Nginx generates ETags for its own served files,
    # but for proxied responses you rely on upstream to send them
}

If your upstream app doesn’t send ETags, compute them in a middleware layer. In Express:

const etag = require('etag');

app.use('/api', (req, res, next) => {
    const originalJson = res.json.bind(res);
    res.json = (body) => {
        const serialized = JSON.stringify(body);
        const tag = etag(serialized);
        res.setHeader('ETag', tag);
        // Honor conditional requests
        if (req.headers['if-none-match'] === tag) {
            return res.status(304).end();
        }
        res.setHeader('Content-Type', 'application/json');
        res.end(serialized);
    };
    next();
});

Vary: Caching Multiple Representations

Vary tells caches which request headers affect the response. Without Vary, a cache stores one copy per URL. With it, the cache stores one copy per URL per combination of the named headers.

Vary: Accept, Accept-Language

Now the cache stores separate entries for:

  • Accept: application/json + Accept-Language: en
  • Accept: application/json + Accept-Language: de
  • Accept: application/protobuf + Accept-Language: en
  • …

This is correct behavior. Without Vary: Accept, a client asking for Protobuf might get cached JSON.

Gotcha: Vary explosion

If you vary on Authorization, you’ll get one cache entry per unique token. That’s effectively no caching for authenticated endpoints. If you vary on User-Agent, you’ll cache thousands of variants for no real benefit and trash your cache hit rate.

The rule: vary only on headers that genuinely change the response body or semantics. Accept and Accept-Language — yes. Authorization — almost never (use private instead). User-Agent — almost never.

Gotcha: Vary and CDNs

Many CDNs handle Vary poorly or not at all. Cloudflare, for example, only respects Vary: Accept-Encoding natively. For Vary: Accept or Vary: Accept-Language, you need to normalize the header value yourself (in a Worker or Lambda@Edge) and encode the variant into the cache key explicitly.

Fastly handles Vary properly. AWS CloudFront requires you to configure the "Cache Policy" to include specific headers in the cache key — it doesn’t read Vary from the origin response to determine this.

Check your CDN’s documentation before relying on Vary. The spec says one thing; implementations vary wildly (no pun intended).


Surrogate Keys: Surgical Cache Invalidation

This is where it gets powerful. max-age and conditional requests handle freshness. Surrogate keys handle invalidation — proactively telling caches to drop entries when data changes.

The concept: tag every cached response with one or more opaque labels (keys). When the underlying data changes, send a purge request naming the key. Every cached response carrying that tag gets dropped, regardless of whether its TTL has expired.

Different CDNs call these different things:

  • Fastly: Surrogate-Key header
  • Cloudflare: Cache-Tag header
  • Varnish: xkey (with the vmod_xkey module)
  • AWS CloudFront: doesn’t support tag-based invalidation natively — you purge by path pattern

The mechanics are the same across implementations.

How to use surrogate keys

Your API adds a response header listing the relevant keys:

HTTP/1.1 200 OK
Cache-Control: s-maxage=3600
Surrogate-Key: product:42 category:electronics shop:99
Content-Type: application/json

When product 42 is updated in your database, your application sends a purge:

# Fastly
curl -X POST "https://api.fastly.com/service/$FASTLY_SERVICE_ID/purge" \
  -H "Fastly-Key: $FASTLY_API_KEY" \
  -H "Surrogate-Key: product:42"

# Cloudflare
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["product:42"]}'

Every cached response tagged product:42 is gone — product detail pages, category listings, related product sections, search results that included it. All of it. In one API call.

Designing your key taxonomy

Think of surrogate keys like database foreign keys. Every response is tagged with the identifiers of the data it contains. Some useful patterns:

product:{id}          — anything showing this specific product
category:{id}         — anything listing this category
shop:{id}             — anything scoped to this shop
user:{id}:public      — public profile data for a user (NOT private data)
pricing:v{version}    — anything showing prices (flush everything on a pricing update)

The colon-delimited namespace convention is common but not required — your CDN treats these as opaque strings. Pick something readable and consistent.

Gotcha: Don’t put surrogate keys in the browser cache

Surrogate-Key and Cache-Tag headers are stripped by CDNs before forwarding the response to the browser. But check that your reverse proxy or CDN actually strips them. Exposing cache tags to end users leaks your internal data model — they can see which entity IDs are associated with which responses.

Nginx has a proxy_hide_header directive. Varnish strips them in vcl_deliver. Fastly strips Surrogate-Key automatically.

Varnish configuration with xkey

If you’re running Varnish on-premise, here’s a minimal setup using vmod_xkey:

vcl 4.1;

import xkey;

sub vcl_backend_response {
    # Strip any client-set surrogate keys, trust only origin
    unset beresp.http.Surrogate-Key;

    # The origin sets X-Cache-Tags (your internal header name)
    if (beresp.http.X-Cache-Tags) {
        set beresp.http.Surrogate-Key = beresp.http.X-Cache-Tags;
    }
}

sub vcl_deliver {
    # Don't expose the surrogate keys to clients
    unset resp.http.Surrogate-Key;
}

Purge via Varnish’s admin socket or HTTP:

# Purge all objects tagged product:42
varnishadm "xkey.purge product:42"

Or via HTTP purge request to Varnish (configure an ACL to restrict this):

curl -X PURGE -H "X-Purge-Tag: product:42" http://varnish:6081/

Putting It Together: A Real API Response

Here’s what a well-cached API response looks like for a product listing endpoint that’s publicly cacheable:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, s-maxage=3600, stale-while-revalidate=300, stale-if-error=86400
Vary: Accept-Language
ETag: "d4e5f6a7b8"
Surrogate-Key: product:42 product:71 product:99 category:14
X-Cache: MISS

And for an authenticated user-specific endpoint:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: private, no-cache
ETag: "u9a8b7c6d5"
Vary: Authorization

Notice: private, no Surrogate-Key (nothing to purge — it’s not in shared cache), and no-cache so the browser still revalidates with the origin on each navigation, keeping session state consistent.


Nginx Reverse Proxy Cache Configuration

If you’re caching at the Nginx layer before a CDN, here’s a production-usable config:

# Define the cache zone — 100MB in-memory keys, 10GB on disk
proxy_cache_path /var/cache/nginx/api
    levels=1:2
    keys_zone=api_cache:100m
    max_size=10g
    inactive=24h
    use_temp_path=off;

server {
    listen 80;
    server_name api.example.com;

    location /api/ {
        proxy_pass http://upstream_app;

        proxy_cache api_cache;

        # Cache key: include Accept-Language to match Vary header
        proxy_cache_key "$scheme$host$request_uri$http_accept_language";

        # Only cache these status codes
        proxy_cache_valid 200 1h;
        proxy_cache_valid 404 1m;

        # Serve stale while revalidating
        proxy_cache_use_stale error timeout updating
                              http_500 http_502 http_503 http_504;
        proxy_cache_background_update on;

        # Lock: only one request revalidates at a time, others wait
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;

        # Don't cache if upstream says no-store
        proxy_no_cache $upstream_no_cache;
        proxy_cache_bypass $upstream_no_cache;

        # Strip surrogate keys from downstream
        proxy_hide_header Surrogate-Key;
        proxy_hide_header X-Cache-Tags;

        # Add debug header (disable in prod if you want)
        add_header X-Cache-Status $upstream_cache_status;
    }
}

map $upstream_http_cache_control $upstream_no_cache {
    "~*no-store" 1;
    "~*private"  1;
    default      0;
}

proxy_cache_lock is worth highlighting — without it, a popular endpoint expiring simultaneously fires hundreds of requests to your origin (the thundering herd problem). With proxy_cache_lock, only one request goes through; others wait and then get served from the freshly populated cache.


Gotchas Roundup

Query strings blow up your cache. GET /api/products?sort=price&page=1 and GET /api/products?page=1&sort=price are the same resource but different cache keys by default. Normalize query string order before caching. Nginx’s proxy_cache_key can be customized; CDNs usually have query string normalization settings.

CORS and caching don’t mix well. If your API returns different CORS headers based on the Origin request header, add Vary: Origin — or your CDN will serve responses with the wrong Access-Control-Allow-Origin to wrong clients. This causes mysterious cross-origin failures that are hell to debug.

Set-Cookie disables caching in many proxies. If your API sets a cookie anywhere in the response, Nginx and most CDNs will refuse to cache it by default. Strip or ignore Set-Cookie for public endpoints, or move session management to a separate auth endpoint.

Clock skew between origin and cache. max-age is calculated from the Date header on the response. If your origin’s clock is off by minutes, your effective TTL is wrong. Keep NTP synchronized (systemctl status systemd-timesyncd).

Purging by tag is not instantaneous. CDN purges can take 1-10 seconds to propagate globally. Don’t rely on purge + immediate read being consistent. Either tolerate a brief inconsistency window or use a write-through cache pattern where the application updates the cache directly.


Production Checklist

  • Set s-maxage explicitly — never leave it to CDN defaults.
  • Use stale-while-revalidate on high-traffic public endpoints. Latency drops, cache hit rate climbs.
  • Add stale-if-error to survive origin outages gracefully.
  • Audit your Vary headers. Every added field multiplies cache cardinality.
  • Tag every cacheable response with surrogate keys tied to data entity IDs.
  • Wire up event-driven purge on database writes — not cron-based TTL expiry.
  • Test cache behavior with curl -v and inspect X-Cache-Status or CDN-specific debug headers (CF-Cache-Status, X-Served-By on Fastly).
  • Monitor cache hit ratio per endpoint. Anything below 70% for a public endpoint is worth investigating.

HTTP caching isn’t magic, and it’s not a fire-and-forget configuration. It’s a contract between your origin, your caches, and your clients — one that has to be maintained as your data model evolves. The tools covered here give you the precision to cache aggressively without serving wrong data.

Start with Cache-Control headers on every response. Add Vary only where it’s justified. Introduce surrogate keys once you have shared caches that matter. Each layer compounds: the hit rate goes up, the origin load drops, and the P99 latency curves flatten in ways that no amount of query optimization will match.

👁 Views: 202,850 · Unique visitors: 57,407