Skip to content

Caching in REST APIs — Complete Guide

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you'll learn about Caching in REST. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

REST caching leverages HTTP's built-in caching mechanisms: Cache-Control headers, ETags, Last-Modified dates, and conditional requests to reduce server load and latency.

What You'll Learn

By the end of this lesson, you will implement HTTP caching headers, use ETags for conditional requests, configure CDN caching, and understand cache invalidation strategies.

Why It Matters

HTTP caching is REST's biggest advantage over Graphql. With proper caching, REST can serve repeated requests from cache in milliseconds without hitting the server.

Real-World Use

Cloudflare CDN caches REST API responses for hours. A GET /posts request hitting the CDN cache returns in 5ms vs 200ms from the origin server.

REST Caching Flow

sequenceDiagram
    Client->>CDN: GET /posts
    CDN->>Origin: Cache MISS (first request)
    Origin->>CDN: 200 + Cache-Control: public, max-age=3600
    CDN->>Client: Response (cached)
    Client->>CDN: GET /posts (same URL)
    CDN->>Client: Cached response (5ms)

HTTP Cache Headers

# cache_headers.py
from datetime import datetime, timedelta
from typing import Dict, Optional

class CacheHeaderBuilder:
    def __init__(self):
        self.default_max_age = 3600

    def public(self, max_age: Optional[int] = None) -> Dict[str, str]:
        age = max_age or self.default_max_age
        return {
            "Cache-Control": f"public, max-age={age}",
            "Expires": (datetime.utcnow() + timedelta(seconds=age)).strftime(
                "%a, %d %b %Y %H:%M:%S GMT"
            ),
        }

    def private(self, max_age: Optional[int] = None) -> Dict[str, str]:
        age = max_age or self.default_max_age
        return {
            "Cache-Control": f"private, max-age={age}",
        }

    def no_cache(self) -> Dict[str, str]:
        return {
            "Cache-Control": "no-cache, no-store, must-revalidate",
            "Pragma": "no-cache",
        }

    def with_etag(self, data: dict, max_age: int = 3600) -> Dict[str, str]:
        etag = f"W/\"{hash(str(data))}\""
        return {
            "Cache-Control": f"public, max-age={max_age}",
            "ETag": etag,
        }

builder = CacheHeaderBuilder()
print(f"Public:  {builder.public(3600)}")
print(f"Private: {builder.private(600)}")
print(f"NoCache: {builder.no_cache()}")
print(f"ETag:    {builder.with_etag({'id': 1})}")

Expected output:

Public:  {'Cache-Control': 'public, max-age=3600', 'Expires': '...'}
Private: {'Cache-Control': 'private, max-age=600'}
NoCache: {'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache'}
ETag:    {'Cache-Control': 'public, max-age=3600', 'ETag': 'W/"<hash>"'}

Conditional Requests with ETag

# conditional_requests.py
from typing import Any, Dict, Optional

class ETagCache:
    def __init__(self):
        self.resources: Dict[str, Dict] = {}
        self.etags: Dict[str, str] = {}

    def put(self, path: str, data: Dict):
        self.resources[path] = data
        self.etags[path] = f"W/\"{abs(hash(str(data)))}\""

    def get(self, path: str, if_none_match: Optional[str] = None) -> Dict:
        if path not in self.resources:
            return {"status": 404, "data": None}

        current_etag = self.etags[path]

        if if_none_match and if_none_match == current_etag:
            return {"status": 304, "data": None}

        return {
            "status": 200,
            "data": self.resources[path],
            "etag": current_etag,
        }

cache = ETagCache()
cache.put("/users/1", {"id": 1, "name": "Alice"})

etag = cache.get("/users/1")["etag"]

first = cache.get("/users/1")
second = cache.get("/users/1", if_none_match=etag)

print(f"First request:  status={first['status']}, data={first['data'] is not None}")
print(f"Second request: status={second['status']}, data={second['data']}")

Expected output:

First request:  status=200, data=True
Second request: status=304, data=None

CDN Caching Configuration

# cdn_caching.py
from typing import Dict, List, Optional

class CDNConfig:
    def __init__(self):
        self.rules: List[Dict] = []

    def add_rule(self, path_pattern: str, ttl_seconds: int,
                 cache_methods: Optional[List[str]] = None):
        self.rules.append({
            "path": path_pattern,
            "ttl": ttl_seconds,
            "methods": cache_methods or ["GET"],
        })

    def should_cache(self, path: str, method: str) -> Optional[int]:
        for rule in self.rules:
            if path.startswith(rule["path"]) and method in rule["methods"]:
                return rule["ttl"]
        return None

    def summary(self):
        print(f"{'Path Pattern':20s} {'TTL':10s} {'Methods'}")
        print("-" * 45)
        for rule in self.rules:
            ttl = f"{rule['ttl']}s" if rule['ttl'] < 3600 else f"{rule['ttl'] // 3600}h"
            print(f"{rule['path']:20s} {ttl:10s} {rule['methods']}")

cdn = CDNConfig()
cdn.add_rule("/posts", 3600)
cdn.add_rule("/users", 600, ["GET", "HEAD"])
cdn.add_rule("/auth", 0)

path = "/posts/123"
ttl = cdn.should_cache(path, "GET")
print(f"Cache {path} for {ttl}s")

path2 = "/auth/login"
ttl2 = cdn.should_cache(path2, "GET")
print(f"Cache {path2}: {ttl2}s (not cached)")

Expected output:

Cache /posts/123 for 3600s
Cache /auth/login: None (not cached)

Common Mistakes

1. No Cache Headers

Sending responses without Cache-Control headers. Proxies and browsers may still cache with unpredictable behavior.

2. Over-Caching Private Data

Marking user-specific data as public lets CDNs cache it. Use private for user-specific responses.

3. No Cache Invalidation

Updating data without purging cached responses. Clients see stale data for the cache duration.

4. Caching POST Requests

POST responses should not be cached by default. Use POST only for mutations, GET for reads.

5. Ignoring Vary Header

Without Vary: Accept, cached JSON responses may be served to clients expecting XML.

Practice Questions

1. What HTTP header controls caching?

Cache-Control with directives like public, private, max-age, no-cache.

2. What is ETag used for?

Conditional requests. The client sends If-None-Match with the ETag, and the server returns 304 if unchanged.

3. What is the difference between public and private Cache-Control?

Public: any cache (CDN, proxy) can cache. Private: only the browser can cache.

4. How do you invalidate a cached response?

Use a new URL (versioned path), purge the CDN cache, or use short max-age with revalidation.

Challenge

Build a caching layer for a REST API that supports ETag conditional requests, Cache-Control headers, and a CDN purge endpoint for cache invalidation.

FAQ

Does REST caching work for authenticated requests?

Yes, use Cache-Control: private for user-specific data. CDNs will not cache private responses.

What is cache invalidation?

The process of removing or updating cached data when the source data changes. Harder than caching itself.

Can I cache POST responses?

REST conventions say no. POST is not idempotent. Cache GET and HEAD only.

What is the Vary header?

Tells caches that the response varies based on request headers (e.g., Vary: Accept, Authorization).

How long should I cache API responses?

Depends on data freshness needs: seconds for live data, hours for reference data, days for static content.

Mini Project: REST Cache Layer

# rest_cache.py
from typing import Dict, Optional
from time import time

class RESTCache:
    def __init__(self):
        self.cache: Dict[str, Dict] = {}
        self.ttls: Dict[str, float] = {}

    def get(self, url: str) -> Optional[Dict]:
        if url in self.cache and time() < self.ttls[url]:
            return {**self.cache[url], "_from_cache": True}
        return None

    def set(self, url: str, data: Dict, ttl: int = 300):
        self.cache[url] = data
        self.ttls[url] = time() + ttl

    def invalidate(self, url: str):
        self.cache.pop(url, None)
        self.ttls.pop(url, None)

cache = RESTCache()
cache.set("/users", [{"id": 1}], ttl=60)
print(f"Cached: {cache.get('/users') and 'YES'}")
cache.invalidate("/users")
print(f"After invalidate: {cache.get('/users') and 'YES' or 'NO'}")

Expected output:

Cached: YES
After invalidate: NO

What's Next

You understand REST caching. Next, explore GraphQL caching, then versioning in REST.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro