Skip to content

API CachingHTTP Cache, CDN & ETag Guide

DodaTech Updated 2026-06-24 5 min read

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

API Caching uses HTTP headers, ETags, and CDNs to store responses and serve them without recomputation, reducing latency and server load while maintaining freshness.

What You'll Learn

You will learn how to configure HTTP cache headers, implement ETags for conditional requests, set up CDN Caching, and design cache invalidation strategies.

Why Caching Matters

Every uncached API request hits your database, consumes compute, and adds latency. Caching reduces response times from 200ms to under 10ms and cuts server costs by 70-90%. DodaTech's Durga Antivirus Pro threat intelligence API serves 50 million requests daily — Caching is what makes this sustainable.

HTTP Cache Headers

from Flask import Flask, jsonify, make_response
from datetime import datetime, timedelta

app = Flask(__name__)

THREATS_CACHE = {
    "last_updated": "2026-06-24T10:00:00Z",
    "threats": [
        {"id": 1, "name": "Emotet", "severity": "high"},
        {"id": 2, "name": "Mirai", "severity": "medium"},
    ],
}

@app.route("/v2/threats")
def list_threats():
    response = make_response(jsonify(THREATS_CACHE))

    # Cache for 5 minutes in browser, 10 minutes at CDN
    response.headers["Cache-Control"] = "public, max-age=300, s-maxage=600"

    # Vary based on Accept-Encoding to avoid serving gzipped to clients that don't support it
    response.headers["Vary"] = "Accept-Encoding"

    # Last-Modified for conditional requests
    response.headers["Last-Modified"] = THREATS_CACHE["last_updated"]

    return response

Expected response headers:

HTTP/1.1 200 OK
Cache-Control: public, max-age=300, s-maxage=600
Vary: Accept-Encoding
Last-Modified: 2026-06-24T10:00:00Z

ETags for Conditional Requests

ETags are unique identifiers for response content. Clients send If-None-Match to get a 304 Not Modified when the content has not changed.

import hashlib
import json

@app.route("/v2/threats/etag")
def list_threats_etag():
    data = THREATS_CACHE["threats"]

    # Generate ETag from MD5 hash of JSON body
    body = json.dumps(data, sort_keys=True).encode()
    etag = hashlib.md5(body).hexdigest()

    # Check if client has the latest version
    if_none_match = request.headers.get("If-None-Match")
    if if_none_match == f'"{etag}"':
        return "", 304

    response = make_response(jsonify(data))
    response.headers["ETag"] = f'"{etag}"'
    response.headers["Cache-Control"] = "public, max-age=300"
    return response

Expected output:

# First request — full response
curl -i http://localhost:5000/v2/threats/etag
# HTTP/1.1 200 OK
# ETag: "abc123def456"
# Content-Length: 142

# Second request — conditional, content unchanged
curl -i -H 'If-None-Match: "abc123def456"' http://localhost:5000/v2/threats/etag
# HTTP/1.1 304 Not Modified
# Content-Length: 0

CDN Caching with CloudFront / Cloudflare

@app.route("/v2/threats/feed")
def threat_feed():
    response = make_response(jsonify(THREATS_CACHE))

    # CDN should cache for 1 hour
    response.headers["Cache-Control"] = "public, max-age=60, s-maxage=3600"

    # CloudFront: forward only the needed query string params
    response.headers["X-Cache-Config"] = "cdn-cache"

    # Surrogate-Control for Akamai (ignored by browsers)
    response.headers["Surrogate-Control"] = "max-age=3600"

    return response

Expected CDN behavior:

# First request (cache MISS)
→ Origin server processes request, returns 200
→ CDN stores response for 3600 seconds

# Subsequent requests (cache HIT)
→ CDN returns cached response immediately
→ No request reaches origin
→ Response time: ~5ms instead of ~200ms

# After 3600 seconds (cache EXPIRED)
→ CDN revalidates with origin
→ If content unchanged: 304, CDN renews cache
flowchart LR
    C["Client"] --> D["CDN Edge\nCloudFront/Cloudflare"]
    D --> O["Origin Server\nFlask/Django"]
    D --> CACHE["CDN Cache\ns-maxage=3600"]

    C -->|"1. Request"| D
    D -->|"2. Cache MISS"| O
    O -->|"3. Response 200\n+ Cache Headers"| D
    D -->|"4. Store & Forward"| C

    C -->|"5. Repeat Request"| D
    D -->|"6. Cache HIT"| CACHE
    CACHE -->|"7. Fast Response"| C

    style CACHE fill:#bbf7d0,stroke:#16a34a
    style O fill:#dbeafe,stroke:#2563eb
    style D fill:#fef3c7,stroke:#d97706

Cache Invalidation Strategies

import time

CACHE_BUSTER = int(time.time())

@app.route("/v2/threats/<threat_id>")
def get_threat(threat_id):
    threat = fetch_threat(threat_id)
    response = make_response(jsonify(threat))

    # Short TTL for individual resources
    response.headers["Cache-Control"] = "public, max-age=60"

    return response

@app.route("/v2/admin/threats", methods=["POST"])
def create_threat():
    data = request.get_JSON()
    threat_id = insert_threat(data)

    # Invalidate the list cache by bumping the buster
    global CACHE_BUSTER
    CACHE_BUSTER = int(time.time())

    return jsonify({"id": threat_id}), 201

Expected behavior:

# After POST /v2/admin/threats:
# → List cache invalidated via CACHE_BUSTER
# → New threats appear in next GET /v2/threats
# → Individual threat is uncached (just created)

Common Errors

1. Over-Caching Dynamic Data

Setting max-age=86400 on user-specific data means users see stale information for a day. Use private or no-cache for personalized responses.

2. Not Setting Vary Headers

Without Vary: Accept-Encoding, a CDN may serve gzipped content to clients that do not support it. Always set appropriate Vary headers.

3. Cache Busting with Timestamps Only

Using only timestamps for cache busting under high load can cause thundering herd problems. Use a combination of version IDs and staggered TTLs.

4. Ignoring Cache on Error Responses

If your origin returns a 500 error, the CDN should not cache it. Set s-maxage=0 on error responses so clients retry the origin.

5. Stale-While-Revalidate Misuse

stale-while-revalidate serves stale content while fetching fresh data in the background. Use it for non-critical data, not for threat feeds where freshness is security-critical.

Practice Questions

1. What is the difference between max-age and s-maxage?

max-age applies to browser caches. s-maxage applies to shared caches like CDNs and proxies. When both are set, s-maxage overrides max-age for shared caches.

2. How does an ETag conditional request work?

The server generates a hash of the response content. The client sends If-None-Match with the previous ETag. If the content matches, the server returns 304 Not Modified with no body.

3. What does Cache-Control: private mean?

The response is specific to a user and must not be cached by shared caches (CDNs). Only the browser may cache it.

4. Challenge: Design a Caching Strategy for a threat feed that updates every 15 minutes, is consumed by 10,000 partners, and must never serve stale threat data for more than 5 minutes.

Set max-age=60, s-maxage=240 on the CDN. Use a background job that updates the feed every 15 minutes. On feed update, invalidate the CDN cache for the feed URL. Use stale-while-revalidate=120 as a safety net.

Mini Project: Cached Threat API

Build a Flask endpoint for Durga Antivirus Pro's threat list with ETag support, Cache-Control headers, CDN configuration, and a purge endpoint that admin can call to invalidate the cache after a threat database update.

Related Tutorials

RESTful API DesignAPI GatewayRate Limiting


Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-24.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro