API Design · Lesson 17

CDN Caching Strategies for APIs

Design safe CDN cache keys, shared TTLs, revalidation, stale responses, and invalidation workflows for Java and Node.js APIs.

Quick answer

Cache an API at a CDN only when every request mapped to one cache key may receive the same representation. Start with public GET and HEAD responses, explicit Cache-Control, bounded query keys, correct Vary, and a stable ETag. Keep personalized or tenant-specific responses private or no-store.

Short freshness bounds inconsistency. Revalidation saves transfer, stale controls can hide latency or failures for limited periods, and versioned URLs or targeted purge handle urgent change.

RFC 9111 obsoletes RFC 7234.

What changes when a shared cache sits in front of an API?

A CDN can serve one stored response to unrelated clients. List every representation input: URI, media type, language, encoding, tenant, identity, permissions, flags, and version. Keys and Vary must distinguish changing inputs; bypass private or unbounded ones.

RFC 9111 gives requests containing Authorization special protection. A shared cache must not reuse their responses unless the response has public, s-maxage, or must-revalidate, each with its associated requirements. Those directives grant protocol permission; they do not prove the payload is public. A catalog endpoint whose output genuinely ignores identity can be marked public. A user dashboard should remain private, no-store, even if partitioning by token appears possible.

Set-Cookie is not a universal cache prohibition, and Vary: Cookie is not privacy control. Restrict session data, errors, and permission-dependent redirects.

Design the cache key

Start with method and target URI, including query. Add provider fields only as needed. Shared-key responses must be interchangeable.

For /api/catalog?region=us&page=2, keep both dimensions. Exclude parameters only after proving they cannot affect authorization, routing, experiments, content, or signatures. Reject duplicates and generate one order; sorting requires order-independent semantics.

Never discard the whole query: page=1 could satisfy page=2. Arbitrary tracking or attacker parameters instead create cardinality attacks. Allowlist per route and monitor distinct keys.

Never key on raw tokens, cookies, personal data, or unrestricted headers. Prefer bypass or a reviewed bounded opaque partition.

Use Vary without creating variant explosions

Vary declares request header fields that selected the representation. Query parameters belong to the URI key, so Vary: region cannot repair a missing query dimension.

Use the smallest correct set:

Vary: Accept-Encoding

Add Accept or normalized Accept-Language only for real negotiation. Avoid high-cardinality preferences. Vary: * prevents matching without origin forwarding.

Keep Vary on 200 and 304. For later compression, use a weak ETag or a distinct strong tag from each encoded byte sequence.

Choose browser and CDN freshness separately

The standard shared-cache directive s-maxage overrides max-age at shared caches:

Cache-Control: public, max-age=30, s-maxage=120

Browsers see thirty fresh seconds; shared caches see 120. Age: 100 leaves about twenty seconds here.

Because s-maxage implies proxy-revalidate, do not assume stale extensions override it. Portable stale reuse uses one lifetime:

Cache-Control: public, max-age=30, stale-while-revalidate=15, stale-if-error=120

Separate edge freshness requires documented surrogate controls. Document precedence and test browser-facing Cache-Control.

Revalidation and request collapsing

Return ETag or Last-Modified. A stale CDN can validate; 304 refreshes metadata without a body and retains applicable cache fields.

Caches may collapse concurrent misses. Followers wait or receive allowed stale content. Different keys, variants, and privacy rules must remain separate.

Collapsed forwarding makes CDN counts exceed origin counts. Observe both layers.

stale-while-revalidate and stale-if-error

RFC 5861 uses bounded seconds. max-age=60, stale-while-revalidate=20 permits sixty fresh seconds and twenty during asynchronous validation.

stale-if-error=300 permits five stale minutes after eligible errors, not after a fresh 404. Catalogs may tolerate this; authorization and balances usually should not.

Stale responses retain Age. Alert on persistent staleness and test revalidation interactions.

Invalidation, purge, and versioned URLs

Freshness bounds inconsistency when purge fails. Prefer targeted purge or immutable versioned URLs:

/api/schemas/orders.v7.json
/api/catalog/snapshots/2026-07-26T14-00Z.json

Version changes create a new key and avoid purge races. Long freshness plus immutable is safe only if the old URL’s bytes never change.

Map changes to URLs, prefixes, or tags. Purge all key variants, retain operation IDs, and verify a miss plus changed validator. CDN purge does not clear browsers.

Java Spring Boot origin example

This public catalog validates the complete query map. Because compression can happen after the controller, it emits a weak ETag: weak comparison supports conditional GET, but weak tags are unsuitable for If-Match or range recombination.

@RestController
@RequestMapping("/api/catalog")
final class CatalogController {
    private static final String PUBLIC_POLICY =
        "public, max-age=30, stale-while-revalidate=15, stale-if-error=120";
    private static final Set<String> ALLOWED_QUERY =
        Set.of("region", "page");
    private final CatalogService catalog;

    CatalogController(CatalogService catalog) {
        this.catalog = catalog;
    }

    private record CatalogKey(Region region, int page) {}

    private static Optional<CatalogKey> parseKey(
        MultiValueMap<String, String> query
    ) {
        if (!ALLOWED_QUERY.containsAll(query.keySet())
            || query.values().stream().anyMatch(values -> values.size() != 1)) {
            return Optional.empty();
        }
        String regionText =
            Optional.ofNullable(query.getFirst("region")).orElse("us");
        String pageText =
            Optional.ofNullable(query.getFirst("page")).orElse("1");
        if (!pageText.matches("[1-9]\\d{0,2}")) return Optional.empty();

        try {
            Region region =
                Region.valueOf(regionText.toUpperCase(Locale.ROOT));
            int page = Integer.parseInt(pageText);
            return page <= 100
                ? Optional.of(new CatalogKey(region, page))
                : Optional.empty();
        } catch (IllegalArgumentException invalid) {
            return Optional.empty();
        }
    }

    @GetMapping
    ResponseEntity<?> list(
        @RequestParam MultiValueMap<String, String> query,
        WebRequest request
    ) {
        Optional<CatalogKey> parsed = parseKey(query);
        if (parsed.isEmpty()) {
            return ResponseEntity.badRequest()
                .header(HttpHeaders.CACHE_CONTROL, "no-store")
                .body(Map.of("error", "invalid catalog key"));
        }
        CatalogKey key = parsed.get();
        CatalogSnapshot snapshot = catalog.page(key.region(), key.page());
        String etag = "W/\"catalog-"
            + key.region().name().toLowerCase()
            + "-p" + key.page() + "-v" + snapshot.revision() + "\"";

        HttpHeaders headers = new HttpHeaders();
        headers.set(HttpHeaders.CACHE_CONTROL, PUBLIC_POLICY);
        headers.setVary(List.of(HttpHeaders.ACCEPT_ENCODING));
        headers.setETag(etag);

        if (request.checkNotModified(etag)) {
            return new ResponseEntity<>(null, headers, HttpStatus.NOT_MODIFIED);
        }
        return new ResponseEntity<>(snapshot.page(), headers, HttpStatus.OK);
    }

    @GetMapping("/me")
    ResponseEntity<UserCatalog> mine(Principal principal) {
        return ResponseEntity.ok()
            .header(HttpHeaders.CACHE_CONTROL, "private, no-store")
            .body(catalog.forUser(principal.getName()));
    }
}

The revision advances with every visible change. Query duplicates and unknown names fail with non-storable 400; region and page remain key dimensions at the CDN.

MockMvc tests pin public metadata, validation, and the private route:

@Test
void catalogIsPublicAndRevalidates() throws Exception {
    MvcResult first = mockMvc.perform(get("/api/catalog")
            .queryParam("region", "us").queryParam("page", "2"))
        .andExpect(status().isOk())
        .andExpect(header().string(CACHE_CONTROL,
            "public, max-age=30, stale-while-revalidate=15, stale-if-error=120"))
        .andExpect(header().string(VARY, "Accept-Encoding"))
        .andExpect(header().string(ETAG, startsWith("W/\"catalog-")))
        .andReturn();

    String etag = first.getResponse().getHeader(ETAG);
    mockMvc.perform(get("/api/catalog")
            .queryParam("region", "us").queryParam("page", "2")
            .header(IF_NONE_MATCH, etag))
        .andExpect(status().isNotModified())
        .andExpect(header().string(ETAG, etag))
        .andExpect(content().string(""));
}

@Test
void personalCatalogCannotEnterSharedCache() throws Exception {
    mockMvc.perform(get("/api/catalog/me").with(user("alice")))
        .andExpect(status().isOk())
        .andExpect(header().string(CACHE_CONTROL, "private, no-store"));
}

@Test
void rejectsUnknownAndDuplicateQueryDimensions() throws Exception {
    mockMvc.perform(get("/api/catalog")
            .queryParam("region", "us", "eu"))
        .andExpect(status().isBadRequest())
        .andExpect(header().string(CACHE_CONTROL, "no-store"));
    mockMvc.perform(get("/api/catalog").queryParam("utm_x", "1"))
        .andExpect(status().isBadRequest())
        .andExpect(header().string(CACHE_CONTROL, "no-store"));
}

Node.js Express origin example

Express can enforce a bounded query vocabulary before generating cache metadata:

const PUBLIC_POLICY =
  "public, max-age=30, stale-while-revalidate=15, stale-if-error=120";
const ALLOWED_QUERY = new Set(["region", "page"]);

function catalogKey(req, res, next) {
  if (Object.keys(req.query).some(name => !ALLOWED_QUERY.has(name))) {
    return res.status(400).set("Cache-Control", "no-store")
      .json({ error: "unsupported query parameter" });
  }
  const region = req.query.region ?? "us";
  const pageText = req.query.page ?? "1";
  if (!["us", "eu", "apac"].includes(region)
      || !/^[1-9]\d*$/.test(pageText)
      || Number(pageText) > 100) {
    return res.status(400).set("Cache-Control", "no-store")
      .json({ error: "invalid catalog key" });
  }
  res.locals.cacheKey = { region, page: Number(pageText) };
  next();
}

function asyncRoute(handler) {
  return function express4AsyncRoute(req, res, next) {
    Promise.resolve(handler(req, res, next)).catch(next);
  };
}

app.get("/api/catalog", catalogKey, asyncRoute(async (req, res) => {
  const { region, page } = res.locals.cacheKey;
  const snapshot = await catalogService.page(region, page);
  const etag = `W/"catalog-${region}-p${page}-v${snapshot.revision}"`;

  res.set("Cache-Control", PUBLIC_POLICY);
  res.set("ETag", etag);
  res.vary("Accept-Encoding");
  if (req.fresh) return res.status(304).end();
  return res.json(snapshot.page);
}));

app.get("/api/catalog/me", requireUser, asyncRoute(async (req, res) => {
  res.set("Cache-Control", "private, no-store");
  return res.json(await catalogService.forUser(req.user.id));
}));

app.use((error, _req, res, next) => {
  if (res.headersSent) return next(error);
  return res.status(503).set("Cache-Control", "no-store")
    .json({ error: "catalog temporarily unavailable" });
});

asyncRoute forwards rejected promises to Express 4 error middleware. The weak tag asserts semantic equivalence across later gzip or Brotli coding, not byte identity; use it for conditional GET, not If-Match or range recombination. A strong tag must identify the final encoded bytes.

Supertest protects the edge contract:

it("emits reusable metadata and returns 304", async () => {
  const first = await request(app).get("/api/catalog?region=us&page=2");
  assert.equal(first.status, 200);
  assert.equal(first.headers["cache-control"], PUBLIC_POLICY);
  assert.match(first.headers.etag, /^W\/"catalog-/);
  assert.match(first.headers.vary, /Accept-Encoding/i);

  const next = await request(app)
    .get("/api/catalog?region=us&page=2")
    .set("If-None-Match", first.headers.etag);
  assert.equal(next.status, 304);
  assert.equal(next.text, "");
});

it("rejects key noise and protects personal output", async () => {
  const noisy = await request(app).get("/api/catalog?region=us&utm_x=1");
  assert.equal(noisy.status, 400);
  assert.equal(noisy.headers["cache-control"], "no-store");

  const mine = await request(app).get("/api/catalog/me")
    .set("Authorization", "Bearer test-user");
  assert.equal(mine.headers["cache-control"], "private, no-store");
});

it("forwards rejected promises on Express 4", async () => {
  const failure = new Error("offline");
  await new Promise((resolve, reject) => {
    asyncRoute(async () => { throw failure; })({}, {}, error => {
      try {
        assert.equal(error, failure);
        resolve();
      } catch (assertion) {
        reject(assertion);
      }
    });
  });
});

Cloudflare-specific behavior and Cache Rules

The origin examples above are standards-first. Cloudflare-specific behavior belongs in configuration:

  1. Match the public path and GET, HEAD, or Cloudflare’s internal PURGE before selecting Eligible for cache:
http.request.uri.path eq "/api/catalog"
and http.request.method in {"GET" "HEAD" "PURGE"}

This lets the same key rule run during single-file purge. It does not authorize an application PURGE endpoint; block untrusted client PURGE at the edge and origin, and use Cloudflare’s authenticated purge API. 2. Respect origin TTLs unless a reviewed rule overrides them. Edge TTL can disable origin revalidation directives; Browser TTL changes downstream Cache-Control. 3. Keep region and page in the key. Exclude or sort only after proving semantics; features vary by plan. 4. Enable Vary handling in a Cache Rule when using origin Vary.

Cloudflare’s default key includes the full URL and query, Origin, and certain override or forwarding headers. Inspect it with Cloudflare Trace. A Cloudflare-only split policy is:

Cache-Control: public, max-age=30
Cloudflare-CDN-Cache-Control: public, max-age=120, stale-while-revalidate=30, stale-if-error=300

Cloudflare-CDN-Cache-Control is not sent downstream; CDN-Cache-Control is forwarded for supporting CDNs. Cache Rules take precedence. Cloudflare documents that s-maxage disables stale-while-revalidate, so omit it above.

Read CF-Cache-Status: MISS fetched, HIT reused, REVALIDATED received origin 304, UPDATING served stale during refresh, STALE covered origin failure, BYPASS was eligible but response conditions prevented storage, and DYNAMIC was ineligible. Cloudflare emits Age only for cache-served responses; revalidation, purge, eviction, or refill can reset it.

With Origin Cache Control, an authorized request is cached only with public, s-maxage, or must-revalidate. Still bypass it unless deliberately public.

Prefer purge by URL only when the Cache Rule also matches internal PURGE. API URL purge must include every custom-key query and header dimension. Use tag, hostname, or prefix when request properties cannot be reproduced; Cloudflare removes Cache-Tag before delivery.

Verify CDN behavior

curl has no general shared cache of its own, so call the proxied production-like hostname twice:

curl -i "https://cache.example.test/api/catalog?region=us&page=2"
curl -i "https://cache.example.test/api/catalog?region=us&page=2"
curl -i "https://cache.example.test/api/catalog?region=eu&page=2"
curl -i -H "Cache-Control: no-cache" \
  "https://cache.example.test/api/catalog?region=us&page=2"
curl -i -H "Authorization: Bearer test-user" \
  "https://cache.example.test/api/catalog/me"

Expect miss, hit with Age, and a separate region object. Forced validation should reach the origin conditionally. Personal output must never hit across identities.

After expiry, observe validation, change the revision, purge, and confirm a miss plus new ETag. Correlate edge and origin logs.

Observability and tests

Log route template, normalized public dimensions, status, ETag version, conditionals, and latency—not credentials or personal queries. Measure CDN statuses, Age, hit ratio, revalidation, stale duration, bytes saved, origin reduction, key cardinality, and purge failures.

Test the real CDN: cold miss, hit, query and Vary separation, 304, concurrent expiry, bounded stale error, recovery, purge, and two identities. Fail if private output receives a shared hit.

Make key-noise 400, permission-dependent 403/404, and unintended 500 responses non-storable. Load-test cold and purge herds.

Common mistakes

  • Treating every GET as public and cacheable.
  • Removing the whole query string or an authorization-relevant dimension from the key.
  • Adding cookies, tokens, or unrestricted headers to the key to “make personalization safe.”
  • Using high-cardinality Vary: User-Agent or Vary: Cookie.
  • Combining s-maxage and stale directives without checking revalidation semantics.
  • Assuming Age: 0 proves a miss or that a missing Age proves no intermediary exists.
  • Changing content without changing its validator.
  • Purging everything as the normal publication workflow.
  • Testing the origin headers but not the edge rule that overrides them.

Edge cases

HEAD shares GET metadata without a separate body representation. Range responses, compression, redirects, 404, 410, and 206 need review. Signed URLs retain signature inputs until validation. Multi-CDN paths need consistent surrogate precedence and purge. CDN purge cannot clear service workers.

Practical checklist

  • Classify the endpoint as public, private, or non-storable.
  • List every representation input and privacy boundary.
  • Allowlist and normalize query-key dimensions; reject noise.
  • Keep Vary minimal and validators variant-specific.
  • Set explicit browser/shared freshness and understand s-maxage revalidation.
  • Bound stale-while-revalidate and stale-if-error by business tolerance.
  • Emit current cache metadata on 200 and 304.
  • Prefer immutable versioned URLs and targeted purge.
  • Verify miss, hit, bypass, validation, stale, error, and recovery behavior.
  • Monitor Age, key cardinality, stale duration, origin traffic, and isolation failures.

Frequently asked questions

Should an API CDN cache responses to authorized requests?

Usually no. Bypass personalized output even when protocol storage is permitted.

Is Vary: Authorization enough to protect private data?

No. Use private or no-store.

Does purge provide immediate consistency everywhere?

No. Browsers, service workers, proxies, and omitted keys can remain.

What is a good stale-if-error window?

Use the shortest acceptable window; zero suits sensitive decisions.

Why did the second request remain a miss?

Check eligibility, policy, query order, Vary, rule precedence, and edge location.

Sources

Knowledge check

Check your understanding

Answer both questions correctly to mark this lesson as mastered. You can retry without penalty.

1. An API varies catalog responses by region and language; which cache key design preserves the correct representation?

2. During an origin outage, how should a CDN use stale-if-error for a previously cacheable catalog response?