Skip to content

Client Cache

Client-side caching leverages HTTP cache headers to instruct browsers and CDNs to cache responses locally. This reduces server load and improves user experience by serving cached content directly from the client.

Understanding Client Caching

Client caching works by setting HTTP headers that tell browsers, proxies, and CDNs how long they should cache responses. When implemented correctly, subsequent requests for the same resource are served instantly from the local cache.

Benefits of Client Caching

Reduced Latency: Instant response from local cache eliminates network round trips
Lower Server Load: Fewer requests reach your server infrastructure
Bandwidth Savings: Cached responses don't consume network bandwidth
Better User Experience: Faster page loads and improved responsiveness
Cost Reduction: Lower server resource usage and bandwidth costs

Cache-Control Headers

The Cache-Control header is the primary mechanism for controlling client-side caching behavior.

Header Components

Cache-Control: public, max-age=3600, s-maxage=7200, must-revalidate

Directive Breakdown:

  • public: Response can be cached by any cache (browsers, CDNs, proxies)
  • private: Response can only be cached by browsers, not shared caches
  • max-age=3600: Cache for 3600 seconds (1 hour) in browsers
  • s-maxage=7200: Cache for 7200 seconds (2 hours) in shared caches (CDNs)
  • must-revalidate: Must check with server when cache expires
  • no-cache: Must revalidate with server before using cached response
  • no-store: Must not store any part of the response

Common Cache Patterns

# Static assets (images, CSS, JS)
"Cache-Control: public, max-age=31536000, immutable"  # 1 year

# API data that changes rarely
"Cache-Control: public, max-age=3600"  # 1 hour

# User-specific data
"Cache-Control: private, max-age=1800"  # 30 minutes, browser only

# Real-time data
"Cache-Control: no-cache, must-revalidate"  # Always validate

# Sensitive data
"Cache-Control: no-store, no-cache, must-revalidate"  # Never cache

Middleware Implementation

The boilerplate includes middleware that automatically adds cache headers to responses.

ClientCacheMiddleware

# middleware/client_cache_middleware.py
from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint

class ClientCacheMiddleware(BaseHTTPMiddleware):
    """Middleware to set Cache-Control headers for client-side caching."""

    def __init__(self, app: FastAPI, max_age: int = 60) -> None:
        super().__init__(app)
        self.max_age = max_age

    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
        response: Response = await call_next(request)
        response.headers["Cache-Control"] = f"public, max-age={self.max_age}"
        return response

Adding Middleware to Application

# main.py
from fastapi import FastAPI
from app.middleware.client_cache_middleware import ClientCacheMiddleware

app = FastAPI()

# Add client caching middleware
app.add_middleware(
    ClientCacheMiddleware,
    max_age=300  # 5 minutes default cache
)

Custom Middleware Configuration

class AdvancedClientCacheMiddleware(BaseHTTPMiddleware):
    """Advanced client cache middleware with path-specific configurations."""

    def __init__(
        self, 
        app: FastAPI, 
        default_max_age: int = 300,
        path_configs: dict[str, dict] = None
    ):
        super().__init__(app)
        self.default_max_age = default_max_age
        self.path_configs = path_configs or {}

    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
        response = await call_next(request)

        # Get path-specific configuration
        cache_config = self._get_cache_config(request.url.path)

        # Set cache headers based on configuration
        if cache_config.get("no_cache", False):
            response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
            response.headers["Pragma"] = "no-cache"
            response.headers["Expires"] = "0"
        else:
            max_age = cache_config.get("max_age", self.default_max_age)
            visibility = "private" if cache_config.get("private", False) else "public"

            cache_control = f"{visibility}, max-age={max_age}"

            if cache_config.get("must_revalidate", False):
                cache_control += ", must-revalidate"

            if cache_config.get("immutable", False):
                cache_control += ", immutable"

            response.headers["Cache-Control"] = cache_control

        return response

    def _get_cache_config(self, path: str) -> dict:
        """Get cache configuration for a specific path."""
        for pattern, config in self.path_configs.items():
            if path.startswith(pattern):
                return config
        return {}

# Usage with path-specific configurations
app.add_middleware(
    AdvancedClientCacheMiddleware,
    default_max_age=300,
    path_configs={
        "/api/v1/static/": {"max_age": 31536000, "immutable": True},  # 1 year for static assets
        "/api/v1/auth/": {"no_cache": True},                          # No cache for auth endpoints
        "/api/v1/users/me": {"private": True, "max_age": 900},        # 15 min private cache for user data
        "/api/v1/public/": {"max_age": 1800},                         # 30 min for public data
    }
)

Manual Cache Control

Set cache headers manually in specific endpoints for fine-grained control.

Response Header Manipulation

from fastapi import APIRouter, Response

router = APIRouter()

@router.get("/api/v1/static-data")
async def get_static_data(response: Response):
    """Endpoint with long-term caching for static data."""
    # Set cache headers for static data
    response.headers["Cache-Control"] = "public, max-age=86400, immutable"  # 24 hours
    response.headers["Last-Modified"] = "Wed, 21 Oct 2023 07:28:00 GMT"
    response.headers["ETag"] = '"abc123"'

    return {"data": "static content that rarely changes"}

@router.get("/api/v1/user-data")
async def get_user_data(response: Response, current_user: dict = Depends(get_current_user)):
    """Endpoint with private caching for user-specific data."""
    # Private cache for user-specific data
    response.headers["Cache-Control"] = "private, max-age=1800"  # 30 minutes
    response.headers["Vary"] = "Authorization"  # Cache varies by auth header

    return {"user_id": current_user["id"], "preferences": "user data"}

@router.get("/api/v1/real-time-data")
async def get_real_time_data(response: Response):
    """Endpoint that should not be cached."""
    # Prevent caching for real-time data
    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    response.headers["Pragma"] = "no-cache"
    response.headers["Expires"] = "0"

    return {"timestamp": datetime.utcnow(), "live_data": "current status"}

Conditional Caching

Implement conditional caching based on request parameters:

@router.get("/api/v1/posts")
async def get_posts(
    response: Response,
    page: int = 1,
    per_page: int = 10,
    category: str = None
):
    """Conditional caching based on parameters."""

    # Different cache strategies based on parameters
    if category:
        # Category-specific data changes less frequently
        response.headers["Cache-Control"] = "public, max-age=1800"  # 30 minutes
    elif page == 1:
        # First page cached more aggressively
        response.headers["Cache-Control"] = "public, max-age=600"   # 10 minutes
    else:
        # Other pages cached for shorter duration
        response.headers["Cache-Control"] = "public, max-age=300"   # 5 minutes

    # Add ETag for efficient revalidation
    content_hash = hashlib.md5(f"{page}{per_page}{category}".encode()).hexdigest()
    response.headers["ETag"] = f'"{content_hash}"'

    posts = await crud_posts.get_multi(
        db=db,
        offset=(page - 1) * per_page,
        limit=per_page,
        category=category
    )

    return {"posts": posts, "page": page, "per_page": per_page}

ETag Implementation

ETags enable efficient cache validation by allowing clients to check if content has changed.

ETag Generation

import hashlib
from typing import Any

def generate_etag(data: Any) -> str:
    """Generate ETag from data content."""
    content = json.dumps(data, sort_keys=True, default=str)
    return hashlib.md5(content.encode()).hexdigest()

@router.get("/api/v1/users/{user_id}")
async def get_user(
    request: Request,
    response: Response,
    user_id: int
):
    """Endpoint with ETag support for efficient caching."""

    user = await crud_users.get(db=db, id=user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    # Generate ETag from user data
    etag = generate_etag(user)

    # Check if client has current version
    if_none_match = request.headers.get("If-None-Match")
    if if_none_match == f'"{etag}"':
        # Content hasn't changed, return 304 Not Modified
        response.status_code = 304
        return Response(status_code=304)

    # Set ETag and cache headers
    response.headers["ETag"] = f'"{etag}"'
    response.headers["Cache-Control"] = "private, max-age=1800, must-revalidate"

    return user

Last-Modified Headers

Use Last-Modified headers for time-based cache validation:

@router.get("/api/v1/posts/{post_id}")
async def get_post(
    request: Request,
    response: Response,
    post_id: int
):
    """Endpoint with Last-Modified header support."""

    post = await crud_posts.get(db=db, id=post_id)
    if not post:
        raise HTTPException(status_code=404, detail="Post not found")

    # Use post's updated_at timestamp
    last_modified = post["updated_at"]

    # Check If-Modified-Since header
    if_modified_since = request.headers.get("If-Modified-Since")
    if if_modified_since:
        client_time = datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT")
        if last_modified <= client_time:
            response.status_code = 304
            return Response(status_code=304)

    # Set Last-Modified header
    response.headers["Last-Modified"] = last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT")
    response.headers["Cache-Control"] = "public, max-age=3600, must-revalidate"

    return post

Cache Strategy by Content Type

Different types of content require different caching strategies.

Static Assets

@router.get("/static/{file_path:path}")
async def serve_static(response: Response, file_path: str):
    """Serve static files with aggressive caching."""

    # Static assets can be cached for a long time
    response.headers["Cache-Control"] = "public, max-age=31536000, immutable"  # 1 year
    response.headers["Vary"] = "Accept-Encoding"  # Vary by compression

    # Add file-specific ETag based on file modification time
    file_stat = os.stat(f"static/{file_path}")
    etag = hashlib.md5(f"{file_path}{file_stat.st_mtime}".encode()).hexdigest()
    response.headers["ETag"] = f'"{etag}"'

    return FileResponse(f"static/{file_path}")

API Responses

# Reference data (rarely changes)
@router.get("/api/v1/countries")
async def get_countries(response: Response, db: AsyncSession = Depends(async_get_db)):
    response.headers["Cache-Control"] = "public, max-age=86400"  # 24 hours
    return await crud_countries.get_all(db=db)

# User-generated content (moderate changes)
@router.get("/api/v1/posts")
async def get_posts(response: Response, db: AsyncSession = Depends(async_get_db)):
    response.headers["Cache-Control"] = "public, max-age=1800"  # 30 minutes
    return await crud_posts.get_multi(db=db, is_deleted=False)

# Personal data (private caching only)
@router.get("/api/v1/users/me/notifications")
async def get_notifications(
    response: Response, 
    current_user: dict = Depends(get_current_user),
    db: AsyncSession = Depends(async_get_db)
):
    response.headers["Cache-Control"] = "private, max-age=300"  # 5 minutes
    response.headers["Vary"] = "Authorization"
    return await crud_notifications.get_user_notifications(db=db, user_id=current_user["id"])

# Real-time data (no caching)
@router.get("/api/v1/system/status")
async def get_system_status(response: Response):
    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    return {"status": "online", "timestamp": datetime.utcnow()}

Vary Header Usage

The Vary header tells caches which request headers affect the response, enabling proper cache key generation.

Common Vary Patterns

# Cache varies by authorization (user-specific content)
response.headers["Vary"] = "Authorization"

# Cache varies by accepted language
response.headers["Vary"] = "Accept-Language"

# Cache varies by compression support
response.headers["Vary"] = "Accept-Encoding"

# Multiple varying headers
response.headers["Vary"] = "Authorization, Accept-Language, Accept-Encoding"

# Example implementation
@router.get("/api/v1/dashboard")
async def get_dashboard(
    request: Request,
    response: Response,
    current_user: dict = Depends(get_current_user)
):
    """Dashboard content that varies by user and language."""

    # Content varies by user (Authorization) and language preference
    response.headers["Vary"] = "Authorization, Accept-Language"
    response.headers["Cache-Control"] = "private, max-age=900"  # 15 minutes

    language = request.headers.get("Accept-Language", "en")

    dashboard_data = await generate_dashboard(
        user_id=current_user["id"],
        language=language
    )

    return dashboard_data

CDN Integration

Configure cache headers for optimal CDN performance.

CDN-Specific Headers

@router.get("/api/v1/public-content")
async def get_public_content(response: Response):
    """Content optimized for CDN caching."""

    # Different cache times for browser vs CDN
    response.headers["Cache-Control"] = "public, max-age=300, s-maxage=3600"  # 5 min browser, 1 hour CDN

    # CDN-specific headers (CloudFlare example)
    response.headers["CF-Cache-Tag"] = "public-content,api-v1"  # Cache tags for purging
    response.headers["CF-Edge-Cache"] = "max-age=86400"         # Edge cache for 24 hours

    return await get_public_content_data()

Cache Purging

Implement cache purging for content updates:

@router.put("/api/v1/posts/{post_id}")
async def update_post(
    response: Response,
    post_id: int,
    post_data: PostUpdate,
    current_user: dict = Depends(get_current_user)
):
    """Update post and invalidate related caches."""

    # Update the post
    updated_post = await crud_posts.update(db=db, id=post_id, object=post_data)

    # Set headers to indicate cache invalidation is needed
    response.headers["Cache-Control"] = "no-cache"
    response.headers["X-Cache-Purge"] = f"post-{post_id},user-{current_user['id']}-posts"

    # In production, trigger CDN purge here
    # await purge_cdn_cache([f"post-{post_id}", f"user-{current_user['id']}-posts"])

    return updated_post

Best Practices

Cache Duration Guidelines

# Choose appropriate cache durations based on content characteristics:

# Static assets (CSS, JS, images with versioning)
max_age = 31536000  # 1 year

# API reference data (countries, categories)
max_age = 86400     # 24 hours

# User-generated content (posts, comments)
max_age = 1800      # 30 minutes

# User-specific data (profiles, preferences)
max_age = 900       # 15 minutes

# Search results
max_age = 600       # 10 minutes

# Real-time data (live scores, chat)
max_age = 0         # No caching

Security Considerations

# Never cache sensitive data
@router.get("/api/v1/admin/secrets")
async def get_secrets(response: Response):
    response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
    response.headers["Pragma"] = "no-cache"
    response.headers["Expires"] = "0"
    return {"secret": "sensitive_data"}

# Use private caching for user-specific content
@router.get("/api/v1/users/me/private-data")
async def get_private_data(response: Response):
    response.headers["Cache-Control"] = "private, max-age=300, must-revalidate"
    response.headers["Vary"] = "Authorization"
    return {"private": "user_data"}

Client-side caching, when properly implemented, provides significant performance improvements while maintaining security and data freshness through intelligent cache control strategies.