Skip to content

Cache-Friendly API Design: REST API Patterns Optimized for Caching

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Cache. We cover key concepts, practical examples, and best practices to help you master this topic.

Cache-friendly API design structures REST endpoints, response headers, and URL patterns to maximize cacheability, using techniques like resource-oriented URLs, ETags for conditional requests, and Cache-Control directives that enable intermediate caches to serve responses efficiently.

flowchart LR
    Client[Client] -->|GET /api/users/42| CDN[CDN Cache]
    CDN -->|Cache-Control: public, max-age=3600| Cache{Cached?}
    Cache -->|Hit + ETag match| 304[304 Not Modified]
    Cache -->|Miss| API[API Server]
    API -->|ETag: abc123| CDN
    304 --> Client
    API -->|200 + Data| Client

What You'll Learn

  • Resource-oriented URL design for cache-friendly APIs
  • ETags and conditional requests for validation Caching
  • Cache-Control headers for controlling cache behavior
  • Pagination strategies that preserve cacheability

Why It Matters

A cache-friendly API can serve 90%+ of requests from CDN or edge caches, reducing origin server load by 10x and cutting response times from 100ms to 5ms for cached responses. Am API design that ignores cacheability burns infrastructure budget and degrades user experience.

Real-World Use

DodaTech's REST API for file metadata uses resource-oriented URLs (/api/files/{id}), strong ETags based on file content hash, and Cache-Control: public, max-age=3600. The CDN serves 94% of requests without contacting the origin. Conditional requests with If-None-Match reduce the remaining 6% to 304 responses for 80% of them.

Resource-Oriented URLs

Design URLs that represent cacheable resources:

import redis
import json
import hashlib

r = redis.Redis(decode_responses=True)

class CacheFriendlyAPI:
    def __init__(self, redis_client):
        self.r = redis_client

    def generate_etag(self, data):
        """Generate a strong ETag from response data."""
        return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:16]

    def get_resource(self, resource_type, resource_id, if_none_match=None):
        """Get a resource with ETag-based conditional request support."""
        cache_key = f"api:{resource_type}:{resource_id}"
        cached = self.r.get(cache_key)

        if cached:
            data = json.loads(cached)
            current_etag = data.get("_etag")

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

            return {"status": 200, "etag": current_etag, "data": data}

        return {"status": 404, "data": None}

    def create_or_update(self, resource_type, resource_id, data, ttl=3600):
        """Create or update a resource with ETag."""
        etag = self.generate_etag(data)
        data["_etag"] = etag
        data["_type"] = resource_type
        data["_id"] = resource_id

        cache_key = f"api:{resource_type}:{resource_id}"
        self.r.setex(cache_key, ttl, json.dumps(data))

        return {
            "status": 200,
            "etag": etag,
            "location": f"/api/{resource_type}/{resource_id}",
        }

    def invalidate_resource(self, resource_type, resource_id):
        """Invalidate a cached resource."""
        cache_key = f"api:{resource_type}:{resource_id}"
        self.r.delete(cache_key)
        return {"invalidated": True, "resource": f"{resource_type}/{resource_id}"}

api = CacheFriendlyAPI(r)

api.create_or_update("users", 42, {
    "name": "Alice", "email": "alice@example.com", "role": "admin"
})

result = api.get_resource("users", 42)
print(f"First request: status={result['status']}, etag={result['etag']}")

etag = result["etag"]
result = api.get_resource("users", 42, if_none_match=etag)
print(f"Conditional request (same ETag): status={result['status']}")

result = api.get_resource("users", 42, if_none_match="old_etag")
print(f"Conditional request (stale ETag): status={result['status']}")

api.invalidate_resource("users", 42)
result = api.get_resource("users", 42)
print(f"After invalidation: status={result['status']}")

Expected output:

First request: status=200, etag=a1b2c3d4e5f6g7h8
Conditional request (same ETag): status=304
Conditional request (stale ETag): status=200
After invalidation: status=404

Pagination for Cacheable Collections

Design pagination that preserves cacheability:

import redis
import json

r = redis.Redis(decode_responses=True)

class CacheablePagination:
    def __init__(self, redis_client):
        self.r = redis_client

    def build_collection_key(self, resource_type, filters):
        """Build a cache key from resource type and filters."""
        filter_parts = []
        for key, value in sorted(filters.items()):
            filter_parts.append(f"{key}={value}")
        filter_string = "&".join(filter_parts)
        return f"api:{resource_type}:collection:{filter_string}"

    def get_page(self, resource_type, filters, page, page_size=20):
        """Get a page of results with cache check."""
        collection_key = self.build_collection_key(resource_type, filters)
        page_key = f"{collection_key}:page:{page}:size:{page_size}"

        cached = self.r.get(page_key)
        if cached:
            return {"source": "cache", "data": json.loads(cached), "page": page}

        return {"source": "miss", "data": None, "page": page}

    def cache_page(self, resource_type, filters, page, page_size, data, ttl=3600):
        """Cache a page of results."""
        collection_key = self.build_collection_key(resource_type, filters)
        page_key = f"{collection_key}:page:{page}:size:{page_size}"

        total_count = data.get("total", 0)
        total_pages = (total_count + page_size - 1) // page_size

        cache_entry = {
            "items": data.get("items", []),
            "page": page,
            "page_size": page_size,
            "total": total_count,
            "total_pages": total_pages,
            "next": f"?page={page + 1}&size={page_size}" if page < total_pages else None,
            "prev": f"?page={page - 1}&size={page_size}" if page > 1 else None,
        }

        self.r.setex(page_key, ttl, json.dumps(cache_entry))
        return cache_entry

    def invalidate_collection(self, resource_type, filters=None):
        """Invalidate all pages in a collection."""
        import re
        if filters:
            pattern = self.build_collection_key(resource_type, filters) + ":*"
        else:
            pattern = f"api:{resource_type}:collection:*"

        keys = self.r.keys(pattern)
        if keys:
            self.r.delete(*keys)
        return {"invalidated": len(keys) if keys else 0}

pagination = CacheablePagination(r)

filters = {"status": "active", "sort": "name"}
items = [{"id": i, "name": f"Item {i}"} for i in range(1, 101)]

page_data = pagination.cache_page("products", filters, 1, 20, {
    "items": items[:20], "total": 100
})
print(f"Cached page 1: {page_data['total']} total, next={page_data['next'] is not None}")

result = pagination.get_page("products", filters, 1, 20)
print(f"Read page 1: source={result['source']}")

result = pagination.get_page("products", filters, 2, 20)
print(f"Read page 2: source={result['source']}")

result = pagination.invalidate_collection("products", filters)
print(f"Invalidated: {result['invalidated']} cache entries")

Expected output:

Cached page 1: 100 total, next=True
Read page 1: source=cache
Read page 2: source=miss
Invalidated: 1 cache entries

Cache Control Headers

Manage cache behavior with Cache-Control directives:

import redis
import json

r = redis.Redis(decode_responses=True)

class CacheControlManager:
    def __init__(self, redis_client):
        self.r = redis_client

    def cache_policy(self, resource_type):
        """Define cache policies for different resource types."""
        policies = {
            "static_assets": {
                "cache_control": "public, max-age=31536000, immutable",
                "description": "One year, never revalidate",
                "cdn_cacheable": True,
            },
            "api_collections": {
                "cache_control": "public, max-age=300, stale-while-revalidate=3600",
                "description": "5 min fresh, 1 hour stale with background refresh",
                "cdn_cacheable": True,
            },
            "user_profiles": {
                "cache_control": "private, max-age=60",
                "description": "Private (no CDN), 1 minute",
                "cdn_cacheable": False,
            },
            "search_results": {
                "cache_control": "public, max-age=120, stale-if-error=86400",
                "description": "2 min fresh, 24 hour stale on error",
                "cdn_cacheable": True,
            },
            "auth_endpoints": {
                "cache_control": "no-store",
                "description": "Never cache",
                "cdn_cacheable": False,
            },
        }
        return policies.get(resource_type, policies["api_collections"])

    def apply_policy(self, resource_type, response_data):
        """Apply cache policy to a response."""
        policy = self.cache_policy(resource_type)
        return {
            "data": response_data,
            "headers": {
                "Cache-Control": policy["cache_control"],
                "X-Cache-Policy": resource_type,
            },
            "policy": policy,
        }

    def recommend_policy(self, resource_characteristics):
        """Recommend a cache policy based on resource characteristics."""
        freshness = resource_characteristics.get("freshness_requirement", "medium")
        cardinality = resource_characteristics.get("user_specific", "public")
        change_frequency = resource_characteristics.get("change_frequency", "hourly")

        if cardinality == "private":
            return "private, max-age=60"
        elif change_frequency == "static":
            return "public, max-age=31536000, immutable"
        elif change_frequency == "rarely":
            return "public, max-age=86400"
        elif change_frequency == "hourly":
            return "public, max-age=3600, stale-while-revalidate=3600"
        else:
            return "public, max-age=300"

cache_control = CacheControlManager(r)

resources = ["static_assets", "api_collections", "user_profiles", "auth_endpoints"]
for resource in resources:
    policy = cache_control.cache_policy(resource)
    print(f"{resource:20s} -> {policy['cache_control']:45s} ({policy['description']})")

rec = cache_control.recommend_policy({
    "freshness_requirement": "medium",
    "user_specific": "public",
    "change_frequency": "hourly"
})
print(f"\nRecommended policy for hourly API: {rec}")

Expected output:

static_assets         -> public, max-age=31536000, immutable (One year, never revalidate)
api_collections       -> public, max-age=300, stale-while-revalidate=3600 (5 min fresh, 1 hour stale with background refresh)
user_profiles         -> private, max-age=60 (Private (no CDN), 1 minute)
auth_endpoints        -> no-store (Never cache)

Recommended policy for hourly API: public, max-age=3600, stale-while-revalidate=3600

Common Mistakes

  • Using query parameters for everything — /api/users?sort=name&filter=active creates many cache variants. Use path-based URLs for resources: /api/users/active/sort-by-name.
  • Not setting Cache-Control headers — without explicit cache policy, intermediate caches apply default heuristics or cache nothing. Always set Cache-Control on every response.
  • Using POST for cacheable read operations — POST responses are not cached by browsers or CDNs. Use GET for read operations to benefit from caching.
  • Including user-specific data in cacheable responses — if the response varies per user, set Cache-Control: private or no-store. Mixing public and private data in the same response hurts cacheability.
  • Returning 200 instead of 304 for unchanged resources — 304 responses are smaller and faster. Always support If-None-Match and If-Modified-Since conditional requests.

Practice Questions

  1. Why are resource-oriented URLs more cache-friendly than query-parameter-heavy URLs?
  2. How do ETags enable conditional requests and reduce bandwidth?
  3. What is the difference between Cache-Control: public and Cache-Control: private?
  4. How does stale-while-revalidate improve perceived performance?
  5. Why is it important to use GET instead of POST for read operations?

Challenge

Design a cache-friendly REST API for a blog platform. Resources: posts (change hourly, public), comments (change minutely, public), user profiles (change rarely, private), and search results (change per query, public). Propose URL structure, Cache-Control headers, ETag generation Strategy (content hash vs last-modified), and pagination approach. Support conditional requests for all resources.

FAQ

What makes an API cache-friendly?

Resource-oriented URLs, strong ETags, appropriate Cache-Control headers, conditional request support, GET for reads, and stable pagination (cursor-based rather than offset-based). Avoid user-specific data in cacheable responses.

How do ETags improve caching?

ETags let clients ask 'Give me this resource only if it differs from version X'. If unchanged, the server returns 304 Not Modified (no body). This saves bandwidth and processing time for both client and server.

What is stale-while-revalidate?

It's a Cache-Control extension that allows caches to serve stale content while asynchronously fetching a fresh version. Users get instant responses while the cache updates in the background. Improves perceived performance.

Should I use offset or cursor pagination for cacheability?

Cursor pagination is more cache-friendly. Offset-based URLs (?page=2) change when items are added/removed. Cursor-based URLs (?after=id_100) are stable and can be cached indefinitely.

How do I handle authentication in cache-friendly APIs?

Use Cache-Control: private for authenticated responses so CDNs don't cache them. Alternatively, use token-based auth in the Authorization header and set Cache-Control: no-store for sensitive endpoints.

Mini Project

Build a cache-friendly REST API framework that: (1) auto-generates strong ETags from response content, (2) handles If-None-Match and If-Modified-Since headers, (3) applies Cache-Control policies based on resource type configuration, (4) supports cursor-based pagination with stable cache keys, and (5) provides a cache invalidation endpoint for purging specific resources or collections.

What's Next

Continue with Cache Security to learn about securing your cache layer, then explore Cache Rate Limiting for protecting your cache infrastructure.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro