18 Pattern: Cache Control

The Cache Control pattern defines how APIs manage client-side and intermediary (CDN/proxy) caching using HTTP headers.

By leveraging response directives (Cache-Control) alongside conditional request headers (If-None-Match and If-Modified-Since), APIs allow clients to validate whether local content is still fresh—fetching full payload bodies only when resources have actually changed.

18.1. Overview

Caching is essential for building scalable, low-latency REST APIs. Rather than serving identical responses repeatedly, the Cache Control pattern establishes a contract between server, intermediaries, and client caches.

The pattern relies on two complementary mechanisms:

  1. Freshness Control (Cache-Control header): Dictates where and for how long a response can be cached without checking back with the origin server (e.g., max-age, public, private, no-cache, no-store).
  2. Validation / Conditional Requests (304 Not Modified): When cached data expires or requires revalidation, the client sends conditional headers (If-None-Match with an ETag, or If-Modified-Since with a timestamp). If unchanged, the server returns an empty 304 Not Modified response, saving bandwidth and backend processing.

Note: Do not confuse read-side caching (If-None-Match304 Not Modified) with write-side concurrency control (If-Match412 Precondition Failed). Read preconditions optimize fetching; write preconditions prevent lost updates.

18.2. When to Use Cache Control

Use Cache Control when:

  • Serving static or slow-changing resources.
    • e.g., Product catalogs, user profiles, public blog posts, asset metadata, or reference data.
  • You need to reduce backend load and network bandwidth.
    • Offloading repetitive GET requests to edge CDNs or browser caches dramatically reduces compute costs.
  • Client applications require low latency.
    • Serving data directly from local storage or edge locations yields sub-millisecond response times.
  • Bandwidth is constrained (e.g., mobile clients).
    • An empty 304 Not Modified header-only response is significantly smaller than re-transmitting large JSON/XML payloads.

18.3. When NOT to Use Cache Control

Avoid Cache Control (or enforce Cache-Control: no-store) when:

  • Data is highly sensitive or strictly confidential.
    • e.g., Banking transaction records, personal health information (PHI), or auth tokens where storing data in shared or disk-backed caches poses a compliance/security risk.
  • Data changes continuously in real time.
    • e.g., Financial market tickers, live GPS tracking, or high-frequency telemetry.
  • Executing unsafe or state-changing operations.
    • HTTP POST, PUT, PATCH, and DELETE requests are inherently non-cacheable. Caching rules apply strictly to safe read operations (GET, HEAD).

18.4. What the Pattern Looks Like

Below are HTTP request and response interaction flows illustrating initial cache retrieval, conditional revalidation, and client recommendations.

1. Initial Retrieval (Fresh Response)

The client requests a resource for the first time. The server returns full representation with caching directives and validator headers (ETag / Last-Modified).

Request

GET /catalog/products/prd_991 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...

Response

HTTP/1.1 200 OK
Cache-Control: public, max-age=3600, must-revalidate
ETag: "v1-8f2b1a"
Last-Modified: Mon, 03 Aug 2026 10:00:00 GMT
Content-Type: application/json

{
  "id": "prd_991",
  "name": "Wireless Mouse",
  "price": 29.99
}

2. Conditional GET (Unchanged Resource → 304 Not Modified)

After 3600 seconds, the client’s local cache expires. The client revalidates by sending If-None-Match (or If-Modified-Since).

Request

GET /catalog/products/prd_991 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
If-None-Match: "v1-8f2b1a"

Response

The server computes or checks the current ETag. Because the resource has not changed, it returns 304 Not Modified without a body.

HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=3600, must-revalidate
ETag: "v1-8f2b1a"
Last-Modified: Mon, 03 Aug 2026 10:00:00 GMT

(The client re-uses its locally cached representation for another 3600 seconds).


3. Conditional GET (Modified Resource → 200 OK)

If the product price changed on the backend, the server returns the updated representation with a new ETag.

Request

GET /catalog/products/prd_991 HTTP/1.1
Host: api.example.com
If-None-Match: "v1-8f2b1a"

Response

HTTP/1.1 200 OK
Cache-Control: public, max-age=3600, must-revalidate
ETag: "v2-9x4c2b"
Last-Modified: Mon, 03 Aug 2026 14:15:00 GMT
Content-Type: application/json

{
  "id": "prd_991",
  "name": "Wireless Mouse",
  "price": 24.99
}

Recommendations for Clients Using Cache Controls

To maximize API efficiency and avoid stale state bugs, client applications should adhere to these guidelines:

  1. Honor Cache-Control Directives:
    • Do not issue network calls for cached entries while max-age is still fresh, unless forced by explicit user action (e.g., pull-to-refresh).
  2. Store Validators Alongside Payload:
    • Always save response ETag strings and Last-Modified timestamps locally alongside the response payload.
  3. Always Include Conditional Headers on Expiration:
    • When re-fetching expired resources, populate If-None-Match with the stored ETag (and/or If-Modified-Since).
  4. Transparently Handle 304 Not Modified:
    • Treat a 304 Not Modified response as a success status: update local cache expiration timers and return the existing locally stored representation to application logic.
  5. Vary Cache Keys by Relevant Request Parameters:
    • Ensure local cache stores key entries by full URL (including query parameters) and header dependencies specified in the server’s Vary header (e.g., Accept-Language, Authorization).

18.5. Anti-Patterns to Avoid

1. Confusing no-cache with no-store

  • no-cache: Means “you may store this response, but you must revalidate it with the origin server before using it.”
  • no-store: Means “do not store this response or request anywhere in any cache under any circumstances.”
  • Pitfall: Developers often use no-cache intending to disable caching completely for sensitive data, accidentally leaving copies stored on disk.

2. Omitting the Vary header on negotiated responses

HTTP/1.1 200 OK
Cache-Control: public, max-age=86400
/* Missing: Vary: Accept-Encoding, Accept-Language */
  • If an endpoint returns localized content or compressed payloads (gzip/brotli), omitting Vary: Accept-Language, Accept-Encoding causes CDNs to serve cached English/gzip content to Spanish/brotli clients.

3. Cache-busting with query strings instead of HTTP preconditions

GET /catalog/products/prd_991?_nocache=1722696000
  • Forcing cache misses via random URL query string parameters bypasses CDN caches entirely, increasing server load and wasting network bandwidth. Use Cache-Control: max-age=0 or If-None-Match revalidation instead.

4. Returning full payload bodies with 200 OK when preconditions match

  • Returning a 200 OK with the full JSON body when an incoming If-None-Match header matches the current ETag defeats the entire purpose of conditional requests. Compute or lookup the ETag early, and return an empty 304 Not Modified immediately.

18.6. OpenAPI Example

A complete OpenAPI 3.0.3 specification illustrating how to document caching headers (Cache-Control, ETag, Last-Modified, Vary), conditional query headers (If-None-Match, If-Modified-Since), and 304 Not Modified responses.

openapi: 3.0.3
info:
  title: Product Catalog API - Cache Control Example
  version: 1.0.0
servers:
  - url: https://api.example.com

paths:
  /catalog/products/{productId}:
    get:
      summary: Retrieve product details with cache validation
      description: Supports conditional GET requests using 'If-None-Match' or 'If-Modified-Since'. Returns 304 if unchanged.
      tags: [Products]
      parameters:
        - in: path
          name: productId
          required: true
          schema:
            type: string
            example: prd_991
        - in: header
          name: If-None-Match
          required: false
          schema:
            type: string
            example: '"v1-8f2b1a"'
          description: Opaque ETag validator previously received from the server.
        - in: header
          name: If-Modified-Since
          required: false
          schema:
            type: string
            example: "Mon, 03 Aug 2026 10:00:00 GMT"
          description: HTTP date string previously received in 'Last-Modified'.
      responses:
        '200':
          description: Product retrieved successfully (full payload)
          headers:
            Cache-Control:
              schema:
                type: string
                example: "public, max-age=3600, must-revalidate"
              description: Directives for client and proxy caches.
            ETag:
              schema:
                type: string
                example: '"v1-8f2b1a"'
              description: Entity tag validator for conditional revalidation.
            Last-Modified:
              schema:
                type: string
                example: "Mon, 03 Aug 2026 10:00:00 GMT"
              description: HTTP date of the last modification.
            Vary:
              schema:
                type: string
                example: "Accept-Encoding, Authorization"
              description: Headers influencing cache key generation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '304':
          description: Not Modified - Cached representation is still fresh
          headers:
            Cache-Control:
              schema:
                type: string
                example: "public, max-age=3600, must-revalidate"
            ETag:
              schema:
                type: string
                example: '"v1-8f2b1a"'
            Last-Modified:
              schema:
                type: string
                example: "Mon, 03 Aug 2026 10:00:00 GMT"

components:
  schemas:
    Product:
      type: object
      properties:
        id:
          type: string
          example: prd_991
        name:
          type: string
          example: Wireless Mouse
        price:
          type: number
          format: float
          example: 29.99

18.7. Visualizing Cache Control (Mermaid Diagram)

sequenceDiagram
    autonumber
    actor Client as Client App / Cache
    participant CDN as Edge CDN / Proxy
    participant API as API Server
    participant DB as Database

    Note over Client,DB: Phase 1: Initial Uncached Request
    Client->>CDN: GET /catalog/products/prd_991
    CDN->>API: GET /catalog/products/prd_991
    API->>DB: Query product prd_991
    DB-->>API: Return data
    API-->>CDN: 200 OK (Cache-Control: max-age=3600, ETag: "v1-8f2b1a", Body)
    CDN-->>Client: 200 OK (Cache-Control: max-age=3600, ETag: "v1-8f2b1a", Body)
    Note over Client,CDN: Client & CDN store payload + ETag locally

    Note over Client,CDN: Phase 2: Fresh Cache Window (t < 3600s)
    Client->>Client: GET /catalog/products/prd_991
    Note over Client: Served instantly from local cache without network call!

    Note over Client,DB: Phase 3: Expired Cache Revalidation (Unchanged)
    Client->>CDN: GET /catalog/products/prd_991 (If-None-Match: "v1-8f2b1a")
    CDN->>API: GET /catalog/products/prd_991 (If-None-Match: "v1-8f2b1a")
    API->>API: Compute ETag for prd_991 == "v1-8f2b1a"
    API-->>CDN: 304 Not Modified (Header only, NO BODY)
    CDN-->>Client: 304 Not Modified (Header only, NO BODY)
    Note over Client: Client resets local max-age timer to 3600s

    Note over Client,DB: Phase 4: Revalidation After Update
    Client->>CDN: GET /catalog/products/prd_991 (If-None-Match: "v1-8f2b1a")
    CDN->>API: GET /catalog/products/prd_991 (If-None-Match: "v1-8f2b1a")
    API->>API: Compute ETag for prd_991 == "v2-9x4c2b" (Changed!)
    API-->>CDN: 200 OK (ETag: "v2-9x4c2b", Updated Body)
    CDN-->>Client: 200 OK (ETag: "v2-9x4c2b", Updated Body)
    Note over Client: Client updates local cache with new body & ETag