Skip to content

REST Caching Strategies — Cache-Control and ETag for API Performance

DodaTech Updated 2026-06-28 4 min read

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

REST caching uses Cache-Control headers, ETags, and conditional requests to allow clients and intermediaries to cache responses, reducing server load and improving API response times.

What You'll Learn

  • How Cache-Control directives control caching behavior
  • How ETags enable conditional requests
  • How to implement caching for different resource types

Why It Matters

Caching is the single most effective performance optimization for APIs. A well-cached API serves 80%+ of requests from cache, reducing server load by 5x and response times from 200ms to under 10ms. Without caching, every request hits your database.

Real-World Use

DodaTech's threat intelligence API uses aggressive caching. Public threat feeds are cached for 5 minutes with public Cache-Control. User-specific threat queries use ETags for conditional requests. 75% of requests are served from cache, reducing database queries from 10,000/s to 2,500/s.

flowchart LR
    A["Client Request"] --> B{"Has valid\ncache?"}
    B -->|"Yes (fresh)"| C["Return cached\nresponse\n~5ms"]
    B -->|"No (stale)"| D["Send request\nto server"]
    D --> E{"Resource\nchanged?"}
    E -->|"No (304)"| F["Return cached\n+ new headers"]
    E -->|"Yes (200)"| G["Return new\nresponse"]
    style C fill:#bbf7d0,stroke:#16a34a
    style F fill:#fef3c7,stroke:#d97706
    style G fill:#dbeafe,stroke:#2563eb

Cache-Control Directives

from flask import Flask, jsonify, make_response, request
import hashlib
import json
from datetime import datetime, timedelta

app = Flask(__name__)

# Public cacheable resource
@app.route('/api/public/threat-feeds')
def get_threat_feeds():
    feeds = get_feeds()
    response = make_response(jsonify(feeds))

    # Cache for 5 minutes in public caches (CDN, proxy)
    response.headers['Cache-Control'] = 'public, max-age=300'
    response.headers['ETag'] = calculate_etag(feeds)
    response.headers['Expires'] = (
        datetime.utcnow() + timedelta(seconds=300)
    ).strftime('%a, %d %b %Y %H:%M:%S GMT')

    return response

# Private resource (user-specific)
@app.route('/api/user/profile')
def get_user_profile():
    user_id = get_authenticated_user()
    profile = get_profile(user_id)
    response = make_response(jsonify(profile))

    # Private: only the user's browser should cache this
    response.headers['Cache-Control'] = 'private, max-age=60'

    return response

# No cache (sensitive data)
@app.route('/api/auth/token', methods=['POST'])
def get_token():
    response = make_response(jsonify({"token": "..."}))
    response.headers['Cache-Control'] = 'no-store'
    return response

ETag and Conditional Requests

def calculate_etag(data):
    """Generate ETag from response data"""
    serialized = json.dumps(data, sort_keys=True)
    return hashlib.md5(serialized.encode()).hexdigest()

@app.route('/api/products/<int:product_id>')
def get_product(product_id):
    product = get_product_by_id(product_id)
    current_etag = calculate_etag(product)

    # Check If-None-Match header
    if_none_match = request.headers.get('If-None-Match')
    if if_none_match == current_etag:
        # Resource hasn't changed
        return '', 304

    response = make_response(jsonify(product))
    response.headers['ETag'] = current_etag
    response.headers['Cache-Control'] = 'public, max-age=60'
    return response

@app.route('/api/products/<int:product_id>', methods=['PUT'])
def update_product(product_id):
    data = request.json
    updated_product = update_product_in_db(product_id, data)

    # The ETag changes after update
    new_etag = calculate_etag(updated_product)
    response = make_response(jsonify(updated_product))
    response.headers['ETag'] = new_etag
    return response

Last-Modified Headers

@app.route('/api/articles/<int:article_id>')
def get_article(article_id):
    article = get_article_by_id(article_id)
    last_modified = article.updated_at

    # Check If-Modified-Since header
    if_modified_since = request.headers.get('If-Modified-Since')
    if if_modified_since:
        client_time = datetime.fromisoformat(if_modified_since.replace('GMT', '').strip())
        if last_modified <= client_time:
            return '', 304

    response = make_response(jsonify(article.to_dict()))
    response.headers['Last-Modified'] = last_modified.strftime('%a, %d %b %Y %H:%M:%S GMT')
    response.headers['Cache-Control'] = 'public, max-age=300'
    return response

Common Mistakes

1. Not Setting Cache-Control at All

Without Cache-Control, clients and proxies guess caching behavior. Always set explicit Cache-Control headers.

2. Caching Authenticated Responses as Public

User-specific data (profile, orders) must be private to prevent one user's data being served to another.

3. Using Weak ETags When Strong Ones Are Needed

A strong ETag changes when the content changes down to the byte. A weak ETag (prefixed with W/) changes when the meaning changes. Use strong ETags for byte-range requests.

4. Forgetting to Invalidate Cache on Updates

When a resource is updated, its ETag changes. But cached copies at CDNs may still serve stale data. Use purge APIs or short TTLs.

5. Not Varying Cache by Accept-Encoding

If you compress responses, cache must vary by Accept-Encoding or serve different versions for compressed vs uncompressed.

Practice Questions

  1. What is the difference between public and private Cache-Control?
  2. How do ETags enable conditional requests?
  3. What status code indicates the resource hasn't changed?
  4. When would you use no-store vs no-cache?
  5. How do you validate that caching works correctly?

Answers

  1. Public caches (CDNs/proxies) can cache public data; private data only caches in the user's browser. 2. Clients send If-None-Match with the previous ETag; server returns 304 if unchanged. 3. 304 Not Modified. 4. no-store: never cache; no-cache: check with server before using cached version. 5. Check response headers and verify 304 responses for conditional requests.

Challenge

Build a caching layer for a blog API that: uses ETags for all resources, sets appropriate Cache-Control for public (articles) and private (user drafts) resources, implements conditional requests, and includes cache invalidation when resources are updated.

FAQ

What is Cache-Control in REST?

An HTTP header that specifies caching rules: who can cache, how long, and whether validation is required.

What is an ETag?

A unique identifier for a resource version, used in conditional requests to avoid re-downloading unchanged data.

What is the difference between 304 and 200?

304 Not Modified means the client's cached copy is still valid. 200 returns the full response body.

Should I cache API responses that require authentication?

Yes, but use private Cache-Control so the response is only cached for that specific user.

How do I invalidate a cached response?

Change the resource representation (which changes the ETag) or purge the cache at the CDN/proxy level.

Mini Project

Build a REST API with a comprehensive caching Strategy: public resources (catalog, articles) with 5-minute TTL, private resources (user data) with ETag validation, no-store for auth endpoints, and a cache hit-rate monitoring endpoint that tracks cache performance.

What's Next

  • Learn about the layered system constraint with proxies and gateways
  • Explore code-on-demand as an optional REST constraint
  • Continue to resource naming conventions for consistent Api Design

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro