Statelessness & Caching Strategies

Stateless APIs remove session affinity from the server — a prerequisite for horizontal scaling — but shift correctness responsibility onto the HTTP cache layer. This page shows how to encode caching semantics into the OpenAPI contract itself, covering cache-directive vendor extensions, conditional requests with ETags, CDN boundary rules, Spectral CI enforcement, and SDK interceptor patterns. Part of the API Design Fundamentals & Architecture reference.

Problem Framing

Stateless APIs remove session affinity from the server, which is a prerequisite for horizontal scaling — but it shifts correctness responsibility onto the HTTP cache layer. When cache directives are missing, ambiguous, or contradict the resource’s mutation semantics, distributed deployments diverge: CDN nodes serve stale payloads, SDK in-memory stores return outdated ETags, and multi-region replicas expose race conditions that only appear under concurrent mutation. The failure mode is silent — the API returns 200 OK with data that was invalidated three seconds ago.

The fix is not to tune caches reactively. It is to encode caching semantics into the OpenAPI contract itself so that gateways, generated clients, and CI pipelines all derive their behavior from a single source of truth. This page covers how to define those contracts, validate them automatically, and propagate them correctly into server code and SDK interceptors.


Cache directive propagation flow OpenAPI spec defines x-cache-control extensions that are read by CI/CD lint, injected by the API gateway into response headers, consumed by the CDN shared cache, and finally reflected in SDK in-memory cache interceptors. OpenAPI Spec x-cache-control CI Lint Gate Spectral rules API Gateway Header injection CDN / Shared s-maxage, Vary Generated SDK ETag interceptor validates injects caches 304/ETag

Spec Definition

Encode caching metadata directly on each operation using the x-cache-control vendor extension, then expose the actual response headers in the OpenAPI schema so generated clients know what to parse. All YAML below targets OpenAPI 3.1.0.

# openapi.yaml
openapi: "3.1.0"
info:
  title: Platform API
  version: "1.0.0"

paths:
  /v1/users/{id}:
    get:
      operationId: getUserById
      summary: Retrieve a single user resource
      # Vendor extension — parsed by API gateway and CI lint
      x-cache-control:
        max-age: 300          # Client cache TTL (seconds)
        s-maxage: 600         # Shared cache (CDN) TTL
        stale-while-revalidate: 120
        vary:
          - Authorization     # Prevents cross-tenant poisoning
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: If-None-Match
          in: header
          required: false
          schema:
            type: string
            pattern: '^"[a-f0-9]{32}"$'
          description: "ETag value from a previous response; triggers 304 if unchanged."
        - name: If-Modified-Since
          in: header
          required: false
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: User resource
          headers:
            Cache-Control:
              schema: { type: string }
              example: "max-age=300, s-maxage=600, stale-while-revalidate=120"
            ETag:
              schema: { type: string }
              example: '"d41d8cd98f00b204e9800998ecf8427e"'
            Vary:
              schema: { type: string }
              example: "Authorization"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "304":
          description: Not Modified — client cache is fresh
          headers:
            ETag:
              schema: { type: string }

All GET and HEAD operations that are safe per HTTP Method Mapping Guidelines should carry explicit x-cache-control extensions. Mutation operations (POST, PUT, PATCH, DELETE) must not carry a positive max-age and should emit Cache-Control: no-store on their responses.


RFC / Standard Alignment

Directive / Concept Governing RFC HTTP Status Purpose
max-age RFC 9111 §5.2.2.1 n/a Maximum age (seconds) a stored response is considered fresh
s-maxage RFC 9111 §5.2.2.10 n/a Overrides max-age for shared caches (CDNs, reverse proxies)
stale-while-revalidate RFC 5861 §3 n/a Allows serving a stale response while asynchronously refreshing it
no-store RFC 9111 §5.2.2.5 n/a Prevents any storage of the response
Vary RFC 9110 §12.5.5 n/a Lists headers whose values factor into cache key computation
Conditional GET (If-None-Match) RFC 9110 §13.1.2 304 Not Modified Validates stored ETag; server skips body if unchanged
Conditional GET (If-Modified-Since) RFC 9110 §13.1.3 304 Not Modified Timestamp-based variant; lower precision than ETag
ETag RFC 9110 §8.8.3 Opaque entity-tag for cache validation and optimistic concurrency
Cache-Control: no-cache RFC 9111 §5.2.2.4 Forces revalidation before serving cached copy

Implementation Walkthrough — Server Side

Step 1: Gateway Header Injection from the Spec Extension

Read the x-cache-control block at startup and inject headers on every matching response. This keeps caching policy in one place (the spec) and out of individual handler code.

// gateway/cacheHeaderMiddleware.ts
import { paths } from "@generated/openapi-types"; // auto-generated from spec

type XCacheControl = {
  "max-age"?: number;
  "s-maxage"?: number;
  "stale-while-revalidate"?: number;
  "no-store"?: boolean;
  vary?: string[];
};

// Load extension map at startup from the parsed spec JSON
const cacheDirectives: Record<string, XCacheControl> = loadCacheDirectivesFromSpec();

export function applyCacheHeaders(
  operationId: string,
  res: { setHeader: (k: string, v: string) => void }
): void {
  const cfg = cacheDirectives[operationId];
  if (!cfg) return;

  if (cfg["no-store"]) {
    res.setHeader("Cache-Control", "no-store");
    return;
  }

  const parts: string[] = [];
  if (cfg["max-age"] != null) parts.push(`max-age=${cfg["max-age"]}`);
  if (cfg["s-maxage"] != null) parts.push(`s-maxage=${cfg["s-maxage"]}`);
  if (cfg["stale-while-revalidate"] != null)
    parts.push(`stale-while-revalidate=${cfg["stale-while-revalidate"]}`);

  if (parts.length) res.setHeader("Cache-Control", parts.join(", "));
  if (cfg.vary?.length) res.setHeader("Vary", cfg.vary.join(", "));
}
# gateway/cache_headers.py
import json
from pathlib import Path
from typing import Any

_directives: dict[str, dict[str, Any]] = {}

def load_cache_directives(spec_path: str) -> None:
    spec = json.loads(Path(spec_path).read_text())
    for path_item in spec.get("paths", {}).values():
        get_op = path_item.get("get", {})
        op_id = get_op.get("operationId")
        ext = get_op.get("x-cache-control")
        if op_id and ext:
            _directives[op_id] = ext

def apply_cache_headers(operation_id: str, headers: dict[str, str]) -> None:
    cfg = _directives.get(operation_id)
    if not cfg:
        return
    if cfg.get("no-store"):
        headers["Cache-Control"] = "no-store"
        return
    parts = []
    if "max-age" in cfg:
        parts.append(f"max-age={cfg['max-age']}")
    if "s-maxage" in cfg:
        parts.append(f"s-maxage={cfg['s-maxage']}")
    if "stale-while-revalidate" in cfg:
        parts.append(f"stale-while-revalidate={cfg['stale-while-revalidate']}")
    if parts:
        headers["Cache-Control"] = ", ".join(parts)
    if cfg.get("vary"):
        headers["Vary"] = ", ".join(cfg["vary"])

Step 2: ETag Generation and Conditional Response

Compute deterministic ETags from the resource’s version field or a content hash. Return 304 Not Modified when the client’s If-None-Match matches, skipping serialization entirely.

// handlers/userHandler.ts
import { createHash } from "node:crypto";
import type { Request, Response } from "express";

export async function getUserById(req: Request, res: Response): Promise<void> {
  const user = await db.users.findById(req.params.id);
  if (!user) { res.status(404).end(); return; }

  // ETag derived from row version — stable across replicas
  const etag = `"${createHash("md5").update(`${user.id}:${user.updatedAt.getTime()}`).digest("hex")}"`;

  // Conditional GET support
  if (req.headers["if-none-match"] === etag) {
    res.setHeader("ETag", etag);
    res.status(304).end();
    return;
  }

  applyCacheHeaders("getUserById", res);
  res.setHeader("ETag", etag);
  res.json(user);
}

Implementation Walkthrough — Client Side

Step 3: SDK Cache Interceptor with ETag Store

Auto-generated SDKs typically expose a fetch-level hook. Inject an interceptor that reads ETag from responses, stores them keyed by URL, and sends If-None-Match on subsequent requests. On 304, return the previously cached body.

// interceptors/cacheInterceptor.ts
interface CacheEntry { etag: string; body: unknown; cachedAt: number }
const store = new Map<string, CacheEntry>();

export async function cacheInterceptor(
  req: { method: string; url: string; headers: Record<string, string> },
  next: (r: typeof req) => Promise<{ status: number; headers: Record<string, string>; json(): Promise<unknown> }>
) {
  const cacheKey = req.url;
  const entry = store.get(cacheKey);

  if (req.method === "GET" && entry) {
    req.headers["If-None-Match"] = entry.etag;
    const maxAgeMs = parseMaxAge(entry) * 1000;
    if (Date.now() - entry.cachedAt < maxAgeMs) {
      // Serve from store without hitting the network
      return { status: 200, headers: {}, json: async () => entry.body };
    }
  }

  const response = await next(req);

  if (response.status === 304 && entry) {
    // Server confirmed nothing changed; refresh the timestamp
    store.set(cacheKey, { ...entry, cachedAt: Date.now() });
    return { ...response, json: async () => entry.body };
  }

  if (response.status === 200 && req.method === "GET") {
    const etag = response.headers["etag"] ?? response.headers["ETag"];
    if (etag) {
      const body = await response.json();
      store.set(cacheKey, { etag, body, cachedAt: Date.now() });
      return { ...response, json: async () => body };
    }
  }

  return response;
}

function parseMaxAge(entry: CacheEntry): number {
  // Default 300 s; real implementation reads from spec at codegen time
  return 300;
}
# clients/cache_client.py
import httpx
import hashlib
import time
from dataclasses import dataclass, field
from typing import Any

@dataclass
class CacheEntry:
    etag: str
    body: Any
    cached_at: float
    max_age: int = 300

class CacheAwareClient(httpx.AsyncClient):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._store: dict[str, CacheEntry] = {}

    async def request(self, method: str, url: str, **kwargs):
        key = url
        entry = self._store.get(key)

        if method.upper() == "GET" and entry:
            # Serve from store if still fresh
            if time.time() - entry.cached_at < entry.max_age:
                return _synthetic_response(entry.body)
            kwargs.setdefault("headers", {})["If-None-Match"] = entry.etag

        response = await super().request(method, url, **kwargs)

        if response.status_code == 304 and entry:
            self._store[key] = CacheEntry(
                etag=entry.etag, body=entry.body, cached_at=time.time(), max_age=entry.max_age
            )
            return _synthetic_response(entry.body)

        if response.status_code == 200 and method.upper() == "GET":
            etag = response.headers.get("etag")
            if etag:
                body = response.json()
                self._store[key] = CacheEntry(etag=etag, body=body, cached_at=time.time())

        return response

def _synthetic_response(body: Any) -> httpx.Response:
    import json
    return httpx.Response(200, content=json.dumps(body).encode(), headers={"content-type": "application/json"})

For React / SWR consumers, set staleTime from the spec’s max-age value — ideally codegen emits this as a typed constant:

// hooks/useUserQuery.ts
import { useQuery } from "@tanstack/react-query";
import { getUserById, USER_CACHE_MAX_AGE_MS } from "@generated/sdk";

export const useUserQuery = (id: string) =>
  useQuery({
    queryKey: ["users", id],
    queryFn: () => getUserById({ pathParams: { id } }),
    staleTime: USER_CACHE_MAX_AGE_MS,   // constant generated from x-cache-control.max-age
    refetchOnWindowFocus: false,
  });

Edge-Case Handling

Multi-tenant cache isolation. A shared CDN keyed only by URL will serve user A’s cached payload to user B if Vary: Authorization is absent. Validate that every authenticated GET operation declares Vary in both the spec extension and the gateway middleware. Consider Cache-Control: private for responses that must never reach a shared cache.

Concurrent mutation + read race. When a PUT completes at T=0 and a GET fires at T=+5ms, a CDN with a 60-second TTL still serves the old response. Use Cache-Control: no-cache (forces revalidation) on resources with sub-second consistency requirements, or implement surrogate-key invalidation (Cache-Tag / Surrogate-Key headers) so mutations can purge specific cache entries atomically.

Stateless auth token rotation. JWT refresh cycles invalidate the implicit user context without touching resource ETags. If an access token rotates mid-session the Authorization header value changes, which causes CDN Vary: Authorization to create a new cache entry rather than reuse the previous one. This is correct behavior but can spike origin traffic after mass token rotation events. Mitigate by issuing a stable X-Cache-Scope header derived from the user’s stable sub claim and varying on that header instead of the raw token. See Implementing Stateless Authentication Flows for SPAs for token lifecycle patterns.

Paginated or cursor-keyed resources. Cursor-bearing URLs (e.g., /items?cursor=abc123) are distinct cache keys per page. Cache them aggressively with short TTLs and emit Cache-Control: no-store on mutation-dependent list endpoints. Align with Resource Modeling Best Practices to ensure list endpoints are clearly separated from singleton resource paths.

Non-idempotent endpoints with side effects. Never attach positive max-age values to POST or PATCH responses. Per Idempotency Key Implementation, these operations can run once-and-only-once per idempotency key, so caching the response would mask replay attempts with stale data.


Validation and Testing Patterns

Spectral Lint Rules

# .spectral.yaml
rules:
  cache-control-required-on-get:
    description: "All GET operations must declare x-cache-control or a Cache-Control response header."
    severity: error
    given: "$.paths[*].get"
    then:
      field: "x-cache-control"
      function: truthy

  vary-required-on-authenticated-get:
    description: "GET operations that reference a security scheme must declare Vary: Authorization."
    severity: error
    given: "$.paths[*].get[?(@.security)]"
    then:
      field: "x-cache-control.vary"
      function: schema
      functionOptions:
        schema:
          type: array
          contains: { const: "Authorization" }

  no-cache-on-mutations:
    description: "POST/PUT/PATCH responses must not carry positive max-age."
    severity: error
    given: "$.paths[*][post,put,patch].x-cache-control"
    then:
      field: "max-age"
      function: falsy

GitHub Actions Gate

# .github/workflows/api-contract.yml
name: API Contract Validation
on: [pull_request]

jobs:
  lint-cache-policies:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @stoplight/spectral-cli
      - run: spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity error

  contract-tests:
    runs-on: ubuntu-latest
    needs: lint-cache-policies
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - run: npm ci
      - run: npx jest --testPathPattern="cache.contract.test" --forceExit

Contract Test Skeleton

// tests/cache.contract.test.ts
import request from "supertest";
import { app } from "../src/app";

describe("Cache-Control contract", () => {
  it("GET /v1/users/:id returns Cache-Control and ETag", async () => {
    const res = await request(app).get("/v1/users/00000000-0000-0000-0000-000000000001");
    expect(res.status).toBe(200);
    expect(res.headers["cache-control"]).toMatch(/max-age=\d+/);
    expect(res.headers["etag"]).toMatch(/^"[a-f0-9]{32}"$/);
    expect(res.headers["vary"]).toContain("Authorization");
  });

  it("Conditional GET returns 304 on matching ETag", async () => {
    const first = await request(app).get("/v1/users/00000000-0000-0000-0000-000000000001");
    const etag = first.headers["etag"];
    const conditional = await request(app)
      .get("/v1/users/00000000-0000-0000-0000-000000000001")
      .set("If-None-Match", etag);
    expect(conditional.status).toBe(304);
    expect(conditional.body).toEqual({});
  });

  it("POST /v1/users returns no-store Cache-Control", async () => {
    const res = await request(app)
      .post("/v1/users")
      .send({ name: "Test User", email: "[email protected]" });
    expect(res.headers["cache-control"]).toBe("no-store");
  });
});

SDK Generation Impact

How the x-cache-control extension surfaces in different generator targets:

TypeScript (openapi-typescript-codegen / openapi-generator): Add a Mustache/Handlebars template hook on operation.vendorExtensions["x-cache-control"] to emit a per-operation constant (e.g., export const GET_USER_CACHE_MAX_AGE_MS = 300_000) and wire it into the fetch wrapper’s staleTime or Cache-Control parsing logic. The 304 response must also be handled — generators that strip non-2xx success codes will silently break ETag validation.

Python (openapi-python-client): Override the _get_headers template to inject If-None-Match when an ETag is present in the local store. The generated Client class can be subclassed (or monkey-patched via a response hook) to update the store on 200 and short-circuit on 304.

Go (oapi-codegen): Use the additional-properties section to emit CacheMaxAge and CacheVary fields on the generated operation metadata struct. A custom HTTP middleware registered on the generated ClientWithResponses type handles the ETag store and 304 branch.

Key risk: generators that default response.ok to status >= 200 && status < 300 will treat 304 as a failure and re-fetch. Always verify the generator’s 304 handling in the contract test suite before shipping a release.


Anti-Patterns Quick Reference

Anti-pattern Correct approach
Omitting Vary: Authorization on authenticated responses Declare vary: [Authorization] in x-cache-control; enforce with Spectral
Hardcoding max-age in response handlers instead of the spec Define x-cache-control once in openapi.yaml; middleware reads it at startup
Positive max-age on POST or PATCH responses Emit Cache-Control: no-store on all mutation responses
SDK generator stripping 304 as a “non-success” status Explicitly list 304 in the responses map and verify generator handling
Vary: * to avoid cache poisoning Use specific field names (Authorization, Accept); Vary: * makes responses uncacheable
ETag patterns that leak resource internals (e.g., "id-version") Hash with MD5/SHA-256 over an opaque composite; do not expose row IDs
Cache invalidation after mutations left to the client Emit Cache-Tag or X-Invalidated-Keys headers on mutations; codegen interceptors purge on receipt

FAQ

How do I prevent cross-tenant cache poisoning in a shared CDN?

Add Vary: Authorization to every cacheable response that scopes data by identity. Enforce it with the Spectral rule above on all authenticated GET operations. If tenant isolation requirements are stricter (e.g., PCI scope), set Cache-Control: private to exclude the response from any shared cache layer and rely solely on client-side caching.

Can generated SDKs handle cache invalidation automatically after mutations?

Yes. Configure codegen templates to emit mutation interceptors that parse Cache-Tag or X-Invalidated-Keys response headers and call store.delete(key) for each listed key. This must be wired into the generated layer, not hand-written in application code, so it survives spec regeneration.

How do I phase in cache-header enforcement without breaking legacy endpoints?

Use warn severity in your Spectral ruleset for existing routes, promote to error for new operations via a path-prefix filter. Pair with contract tests that assert Cache-Control presence on GET operations. Backfill headers on legacy routes as traffic patterns are verified in staging.

What spec rules prevent stateful caching in distributed systems?

Enforce no-store on endpoints that require real-time consistency, mandate ETag generation for all versioned resources, validate that Authorization appears in Vary directives, and reject GET operations that lack an explicit x-cache-control extension. These four rules collectively close the most common sources of cache-driven state divergence.