REST API 17 ๐ Caching Strategies (Cache-Control, ETag, Last-Modified)
HTTP caching is not a single header but a layered contract between the server, the client, and any intermediary caches. Cache-Control defines how long a response remains fresh and who may store it. ETag and Last-Modified define how to verify whether a stored response is still valid after freshness expires. The server that sends only Cache-Control without validators forces clients to re-download the full response after the TTL lapses. The server that sends only validators without Cache-Control forces clients to revalidate on every request, even when the data has not changed for hours. The combination is what produces the bandwidth savings and latency reduction that caching promises.
The three mechanisms are not interchangeable. Cache-Control is a directive: it tells caches what they are allowed to do. ETag is an opaque validator: it is a fingerprint of the response body that the client returns in If-None-Match to ask “has this changed?” Last-Modified is a timestamp validator: the client returns it in If-Modified-Since and the server compares it against the resource’s modification time. RFC 9111 replaced RFC 7234 as the definitive caching specification, and it preserves the precedence rule: when both validators are present, ETag takes priority over Last-Modified .
This chapter covers the Cache-Control directives that matter for APIs, the strong-versus-weak ETag distinction and how to generate each, the Last-Modified fallback and its granularity limitation, the conditional request flow that produces 304 Not Modified, and the invalidation strategies that prevent stale data from persisting. It assumes familiarity with HTTP status codes and the request/response cycle.
Key point: Cache-Control controls freshness; ETag and Last-Modified control validation. A response without validators cannot be revalidatedโonly re-downloaded. A response with validators but without Cache-Control is revalidated on every request, which saves bandwidth but not latency. The correct pattern sends both, with ETag as the primary validator and Last-Modified as the HTTP/1.0 fallback.
Why caching requires explicit headers
Heuristic caching is unpredictable. When a response lacks Cache-Control, browsers and intermediaries may apply heuristic freshness based on Last-Modified or other signals. The result varies by cache implementation, browser version, and response headers. An API endpoint that returns user data without Cache-Control: private may be cached by a shared proxy and served to another user. The fix is to always set Cache-Control explicitly, never relying on the cache to guess .
Validators enable bandwidth savings without sacrificing freshness. An ETag or Last-Modified header allows the client to send a conditional request. If the resource has not changed, the server returns 304 Not Modified with no body. The client reuses its stored copy. This is the mechanism behind GitHub’s API, where conditional requests that return 304 do not count against the rate limit . The bandwidth saved is the entire response body; the latency saved is the serialization and transmission time.
The private directive prevents cross-user leaks. A response marked public may be stored by any cache, including shared proxies and CDNs. A response marked private may be stored only by the client’s browser. For authenticated endpoints that return user-specific data, private is mandatory. Without it, a misconfigured CDN can serve one user’s account data to another .
The trade-off. Every cached response is a potential stale response. max-age is a promise that the data will not change for that many seconds. If the data changes before the TTL expires, the client sees stale data. The mitigations are shorter TTLs, event-based invalidation, and directives like stale-while-revalidate that serve stale data while fetching a fresh copy in the background. The choice depends on the business cost of staleness versus the performance cost of revalidation.
a. Cache-Control: directives, freshness, and scope
The Cache-Control header contains one or more directives that control storage and reuse. The directives that matter for APIs are public, private, no-cache, no-store, max-age, s-maxage, must-revalidate, stale-while-revalidate, and immutable .
public allows any cache to store the response. private restricts storage to the client’s browser. no-cache allows storage but requires revalidation before every reuseโthe cache must check with the origin before serving the stored copy. no-store prohibits storage entirely, for responses that must never persist (financial data, PII) . The distinction between no-cache and no-store is a common point of confusion: no-cache means “store but check first”; no-store means “do not store.”
max-age=N sets the freshness lifetime in seconds. A response with max-age=60 is fresh for 60 seconds; after that, the cache must revalidate or re-fetch. s-maxage=N overrides max-age for shared caches (CDNs, proxies), allowing a longer TTL at the edge than in the browser. stale-while-revalidate=N allows a stale response to be served for N seconds beyond max-age while the cache fetches a fresh copy in the background. immutable tells the cache that the response body will never change for this URL, so revalidation is unnecessaryโuseful for versioned assets .
A sensible default for an authenticated read endpoint is private, max-age=60, stale-while-revalidate=120 . This keeps user data out of shared caches, allows the browser to serve a cached copy for one minute, and serves a slightly stale copy for up to two more minutes while refreshing in the background. For public reference data, public, max-age=300, s-maxage=86400, stale-while-revalidate=86400 gives the CDN a 24-hour TTL and the browser a 5-minute TTL .
b. ETag: strong versus weak, and how to generate them
An ETag is an opaque string that identifies a specific version of a response body. The server sends it in the ETag header. The client stores it and sends it back in If-None-Match on the next request for the same URL. The server compares the client’s ETag against the current resource’s ETag. If they match, the server returns 304 Not Modified with no body. If they differ, the server returns 200 OK with the new body and a new ETag .
ETags come in two flavors. A strong ETag (ETag: "33a64df551425fcc") promises byte-for-byte equality. Two responses with the same strong ETag are identical. A weak ETag (ETag: W/"33a64df551425fcc") promises semantic equivalence: the bytes may differ (field ordering, whitespace, a timestamp), but the meaning is the same. Strong ETags are required for byte-range requests and for optimistic concurrency control with If-Match. Weak ETags are acceptable when the representation can vary without changing the underlying data .
The generation strategy matters for performance. A body hash (MD5 or SHA-1 of the serialized response) is accurate by construction but requires building the full response on every request, including the requests that return 304. A version column or updated_at timestamp derived from the database is cheaper: a conditional request costs one indexed lookup instead of full serialization. The version must bump on every change that affects the response, including changes in joined tables. Missing a bump produces stale 304s, which are invisible bugs . The recommendation is to start with body hashing for correctness and move hot endpoints to version-based ETags when profiling shows serialization cost matters.
Compression middleware can rewrite strong ETags to weak ones when it gzips a response on the fly, because the compressed bytes no longer match the original. If concurrency checks fail behind a proxy, look for a W/ prefix that was not present when the application server sent the response .
c. Last-Modified: the fallback validator
Last-Modified is a timestamp indicating when the origin server believes the resource was last modified. The client sends it back in If-Modified-Since. The server compares the timestamp against the resource’s current modification time. If the resource has not been modified since the client’s timestamp, the server returns 304 Not Modified .
Last-Modified has two limitations that make ETag the preferred validator for APIs. First, its granularity is one second. A resource that changes twice within the same second may have the same Last-Modified value for both versions, causing a 304 to be returned for a request that should receive the new version. Second, it is susceptible to clock skew between the server that generated the timestamp and the cache that stores it .
Despite these limitations, Last-Modified serves an important compatibility purpose. HTTP/1.0 clients and caches ignore ETags but understand Last-Modified. The W3C recommendation is for origin servers to send both a strong ETag and a Last-Modified value. HTTP/1.1 clients use the ETag in If-None-Match and fall back to Last-Modified in If-Modified-Since when no ETag is present . When both validators are present, If-None-Match takes precedence per RFC 9110 .
The conditional request flow is the same for both validators. The client sends the validator from its stored response. The server evaluates the condition. A match produces 304 Not Modified with the same Cache-Control, ETag, and Last-Modified headers as the original response would have had, but no body . A mismatch produces 200 OK with the new body and new validators.
Complete Example Session
# ============================================
# PART 1: INITIAL RESPONSE WITH FULL HEADERS
# ============================================
GET /v1/products/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400
ETag: "9f8b2c41aa73e0d5"
Last-Modified: Wed, 15 Oct 2025 14:30:00 GMT
{
"id": 42,
"name": "Widget",
"price": 29.99
}
# ============================================
# PART 2: CONDITIONAL REQUEST (ETAG)
# ============================================
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "9f8b2c41aa73e0d5"
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=300, s-maxage=3600
ETag: "9f8b2c41aa73e0d5"
Last-Modified: Wed, 15 Oct 2025 14:30:00 GMT
# No body. Client uses its stored copy.
# ============================================
# PART 3: CONDITIONAL REQUEST (LAST-MODIFIED FALLBACK)
# ============================================
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-Modified-Since: Wed, 15 Oct 2025 14:30:00 GMT
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=300
Last-Modified: Wed, 15 Oct 2025 14:30:00 GMT
# ============================================
# PART 4: RESOURCE CHANGED โ NEW ETAG
# ============================================
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "9f8b2c41aa73e0d5"
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300
ETag: "a1b2c3d4e5f6a7b8"
Last-Modified: Wed, 15 Oct 2025 15:00:00 GMT
{
"id": 42,
"name": "Widget",
"price": 24.99
}
# ============================================
# PART 5: PRIVATE USER DATA
# ============================================
GET /v1/me HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "user-42-v17"
# private: browser only, no CDN.
# max-age=60: fresh for 1 minute.
# stale-while-revalidate: serve stale up to 2 min.
# ============================================
# PART 6: NO-STORE FOR SENSITIVE DATA
# ============================================
GET /v1/transactions HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Cache-Control: no-store
# No caching at all. Financial data.
# ============================================
# PART 7: OPTIMISTIC CONCURRENCY WITH IF-MATCH
# ============================================
PUT /v1/products/42 HTTP/1.1
Host: api.example.com
If-Match: "9f8b2c41aa73e0d5"
Content-Type: application/json
{"price": 24.99}
HTTP/1.1 200 OK
ETag: "a1b2c3d4e5f6a7b8"
# If-Match mismatch โ 412 Precondition Failed.
# Prevents lost updates when two clients edit concurrently.
# ============================================
# PART 8: INVALIDATION ON WRITE
# ============================================
POST /v1/products/42/reviews HTTP/1.1
Host: api.example.com
HTTP/1.1 201 Created
Cache-Control: no-store
# Response itself not cached; but the POST invalidates
# the cached GET /v1/products/42 on the client and CDN.
# ============================================
# PART 9: VERSIONED ASSET (IMMUTABLE)
# ============================================
GET /assets/app.abc123.js HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
ETag: "abc123"
# URL contains version. Content never changes.
# immutable skips revalidation entirely.
# ============================================
# PART 10: THE DECISION FLOW
# ============================================
# Is the data user-specific? โ private
# Is it sensitive (PII, financial)? โ no-store
# Is it public reference data? โ public + s-maxage
# Is the URL versioned? โ immutable
# Otherwise โ max-age + stale-while-revalidate
# Always send ETag (strong by default).
# Send Last-Modified for HTTP/1.0 fallback.
Quick Reference
Cache-Control Directives
| Directive | Effect | Use For |
|---|---|---|
public | Any cache may store | Public data |
private | Browser only | User-specific data |
no-cache | Store but revalidate every use | Freshness-critical with 304s |
no-store | Never store | PII, financial data |
max-age=N | Fresh for N seconds | Primary TTL |
s-maxage=N | CDN TTL override | Edge caching |
stale-while-revalidate=N | Serve stale N seconds while refreshing | Read-heavy endpoints |
immutable | Never revalidate | Versioned URLs |
Validator Comparison
| Aspect | ETag | Last-Modified |
|---|---|---|
| Type | Opaque string | Timestamp |
| Granularity | Exact | 1 second |
| Priority | Primary (RFC 9110) | Fallback |
| Header (request) | If-None-Match | If-Modified-Since |
| Concurrency control | If-Match โ 412 | Not applicable |
| HTTP/1.0 support | Ignored | Supported |
ETag Types
| Type | Prefix | Guarantee | Use For |
|---|---|---|---|
| Strong | None | Byte-for-byte identical | Range requests, If-Match |
| Weak | W/ | Semantic equivalence | Variant representations |
Caching by Data Type
| Data Type | Cache-Control | Rationale |
|---|---|---|
| Static reference | public, max-age=300, s-maxage=86400 | Rarely changes |
| Product catalog | public, max-age=300, s-maxage=3600 | High read volume |
| User-specific | private, max-age=60 | No CDN |
| Financial/PII | no-store | Never cache |
| Versioned assets | public, max-age=31536000, immutable | URL versioned |
Best Practices
โ Do This:
# Always set Cache-Control explicitly. // โ
Cache-Control: private, max-age=60, stale-while-revalidate=120
# Send both ETag and Last-Modified. // โ
ETag: "9f8b2c41aa73e0d5"
Last-Modified: Wed, 15 Oct 2025 14:30:00 GMT
# Use strong ETags by default. // โ
ETag: "9f8b2c41aa73e0d5"
# Return 304 with no body on match. // โ
HTTP/1.1 304 Not Modified
# Use private for user-specific data. // โ
Cache-Control: private, max-age=60
โ Don’t Do This:
# Rely on heuristic caching (no Cache-Control). // โ
# Unpredictable; may leak user data via shared caches.
# Use public for authenticated endpoints. // โ
Cache-Control: public # CDN may serve user A's data to user B.
# Use Last-Modified as the only validator. // โ
# 1-second granularity; misses rapid changes.
# Use weak ETags for If-Match concurrency control. // โ
If-Match: W/"..." # Weak ETags not allowed for If-Match.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Cross-user data leak | public on authenticated endpoint | Use private |
| Stale data after TTL | No revalidation or invalidation | must-revalidate or shorter TTL |
| 304 for changed resource | ETag not bumped on update | Version column or body hash |
| Compression rewrites ETag | gzip changes bytes | Accept weak ETag or disable compression |
| Lost updates | No If-Match on writes | Add If-Match with ETag |
| CDN serves stale after origin down | stale-if-error not configured | Set explicitly or stale-if-error=0 |
Real-World Examples
1. Public Reference Data
Cache-Control: public, max-age=300, s-maxage=86400, stale-while-revalidate=86400
2. User-Specific Endpoint
Cache-Control: private, max-age=60, stale-while-revalidate=120
3. Financial Data
Cache-Control: no-store
4. Versioned Asset
Cache-Control: public, max-age=31536000, immutable
5. Strong ETag
ETag: "9f8b2c41aa73e0d5"
6. Weak ETag
ETag: W/"9f8b2c41aa73e0d5"
7. Conditional Request
If-None-Match: "9f8b2c41aa73e0d5"
8. 304 Response
HTTP/1.1 304 Not Modified
ETag: "9f8b2c41aa73e0d5"
9. Optimistic Concurrency
If-Match: "9f8b2c41aa73e0d5"
# Mismatch โ 412 Precondition Failed
10. Last-Modified Fallback
Last-Modified: Wed, 15 Oct 2025 14:30:00 GMT
If-Modified-Since: Wed, 15 Oct 2025 14:30:00 GMT
Visual
Caching Layers
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CACHE-CONTROL vs VALIDATORS โ
โ โ
โ Cache-Control: โ
โ "How long is this fresh?" โ
โ "Who may store it?" โ
โ Controls STORAGE and FRESHNESS. โ
โ โ
โ ETag / Last-Modified: โ
โ "Has this changed?" โ
โ Controls VALIDATION. โ
โ โ
โ Fresh response: โ
โ No request to origin. โ
โ Cache-Control is enough. โ
โ โ
โ Stale response: โ
โ Revalidate with ETag/Last-Modified. โ
โ 304 โ reuse. 200 โ replace. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Conditional Request Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ VALIDATION FLOW โ
โ โ
โ Client stores: โ
โ ETag: "abc123" โ
โ Last-Modified: Wed, 15 Oct 14:30 GMT โ
โ โ
โ Next request: โ
โ If-None-Match: "abc123" โ
โ If-Modified-Since: Wed, 15 Oct 14:30 GMT โ
โ โ โ
โ โผ โ
โ Server compares: โ
โ ETag matches? โ 304 Not Modified โ
โ ETag differs? โ 200 OK + new body โ
โ โ
โ ETag takes precedence over Last-Modified. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Strong vs Weak ETag
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ETAG TYPES โ
โ โ
โ Strong: ETag: "abc123" โ
โ Byte-for-byte identical. โ
โ Required for If-Match (concurrency). โ
โ Required for byte-range requests. โ
โ โ
โ Weak: ETag: W/"abc123" โ
โ Semantically equivalent. โ
โ Bytes may differ (gzip, field order). โ
โ NOT allowed for If-Match. โ
โ โ
โ Compression middleware: โ
โ May rewrite strong โ weak. โ
โ Causes If-Match failures behind proxy. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Invalidation Decision
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHEN DATA CHANGES โ
โ โ
โ Time-based (max-age): โ
โ Data stale after TTL. โ
โ Simple. May serve stale. โ
โ โ
โ Event-based (invalidation): โ
โ Purge cache on write. โ
โ Precise. Requires infrastructure. โ
โ โ
โ Version-based (ETag bump): โ
โ New version โ new ETag. โ
โ Client revalidates, gets 200. โ
โ โ
โ Hybrid: max-age as fallback + โ
โ event invalidation on critical writes. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
Cache-Control | Controls freshness and storage scope |
ETag | Opaque validator, primary |
Last-Modified | Timestamp validator, fallback |
| Strong ETag | Byte-for-byte, required for If-Match |
| Weak ETag | Semantic equivalence, W/ prefix |
| Conditional request | If-None-Match / If-Modified-Since |
| Match response | 304 Not Modified, no body |
| Mismatch response | 200 OK, new body + validators |
private | Browser-only cache |
no-store | Never cache |
Key takeaways:
Cache-Controland validators solve different problems.Cache-Controlcontrols freshness and storage scope.ETagandLast-Modifiedcontrol validation after freshness expires. A response without validators cannot be revalidated; a response withoutCache-Controlis revalidated on every request .privateprevents cross-user leaks. Authenticated endpoints that return user-specific data must useCache-Control: privateto prevent CDNs and shared proxies from serving one user’s data to another .- Strong ETags are the default; weak ETags are for variant representations. Strong ETags promise byte equality and are required for
If-Matchconcurrency control. Weak ETags promise semantic equivalence and are not allowed forIf-Match. Last-Modifiedis a fallback, not a primary validator. Its one-second granularity and clock-skew susceptibility make it less reliable thanETag. Send both; clients useETagwhen available and fall back toLast-Modifiedfor HTTP/1.0 compatibility .If-Matchprevents lost updates. Two clients editing the same resource can overwrite each other.If-Matchwith the current ETag makes the update conditional: if the ETag has changed, the server returns412 Precondition Failedinstead of applying a stale update .- Invalidation is the hard part. Time-based
max-ageis simple but may serve stale data. Event-based invalidation is precise but requires infrastructure. The hybrid approach usesmax-ageas a fallback and triggers invalidation on critical writes .
Remember: Caching is a contract in two parts. Cache-Control tells caches how long they may serve a response without asking the origin. Validators tell caches how to ask whether the response has changed. The first saves requests entirely; the second saves bandwidth when the answer is “nothing changed.” Use private for user data, no-store for secrets, and public with s-maxage for reference data. Generate strong ETags from a content hash or a version column, send Last-Modified alongside for compatibility, and support If-Match on writes to prevent lost updates. The headers are small; the difference between a correctly cached API and a misconfigured one is measured in bandwidth, latency, and data leaks.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!