Setting Up HTTP Caching (ETag, Last-Modified, Vary) for APIs
HTTP caching is built into the protocol, but most APIs ignore it—returning Cache-Control: no-cache or nothing at all. As a result, clients make the same requests repeatedly, sending identical data across the network. Properly configured conditional caching reduces traffic and server load with minimal code changes.
Caching Models
Strong caching (Cache-Control: max-age): the client doesn't contact the server at all until the TTL expires. Suitable for static data: reference tables, versioned resources.
Conditional requests (ETag / Last-Modified): the client contacts the server but sends a validator. The server responds with 304 Not Modified without a body if the data hasn't changed. Saves bandwidth but not round-trip time.
For real-time APIs, use a combination: short max-age for intermediate caches + ETag for conditional requests.
ETag
ETag is a hash of the resource representation. The client receives it in the response, saves it, and sends it back in If-None-Match:
# First request
GET /api/v1/products/42 HTTP/1.1
# Server response
HTTP/1.1 200 OK
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Cache-Control: private, max-age=0, must-revalidate
Content-Type: application/json
{"id": 42, "name": "Widget", "price": 99.00}
# Repeat request
GET /api/v1/products/42 HTTP/1.1
If-None-Match: "d41d8cd98f00b204e9800998ecf8427e"
# If unchanged
HTTP/1.1 304 Not Modified
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Implementation in Laravel:
public function show(Product $product): JsonResponse
{
$etag = md5($product->updated_at . $product->id);
if (request()->hasHeader('If-None-Match')) {
$clientEtag = trim(request()->header('If-None-Match'), '"');
if ($clientEtag === $etag) {
return response()->json(null, 304)
->header('ETag', '"' . $etag . '"');
}
}
return response()->json($product)
->header('ETag', '"' . $etag . '"')
->header('Cache-Control', 'private, max-age=0, must-revalidate');
}
For collections, compute the ETag based on the maximum updated_at in the result set:
$maxUpdated = $products->max('updated_at');
$count = $products->count();
$etag = md5($maxUpdated . $count . $page);
Last-Modified
Simpler than ETag but less precise—second-level accuracy. Used alongside or instead of ETag:
$lastModified = $product->updated_at->toRfc7231String();
if (request()->hasHeader('If-Modified-Since')) {
$since = Carbon::parse(request()->header('If-Modified-Since'));
if ($product->updated_at->lte($since)) {
return response(null, 304)
->header('Last-Modified', $lastModified);
}
}
return response()->json($product)
->header('Last-Modified', $lastModified)
->header('Cache-Control', 'public, max-age=60');
Vary
Vary tells intermediate caches (CDN, proxy) which request headers affect the response. Without it, the CDN will cache the response for one variant and serve it to everyone.
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Vary: Accept-Language, Accept-Encoding
Content-Language: ru
Common use cases for Vary:
| Scenario | Vary |
|---|---|
| Multi-language API | Accept-Language |
| gzip/br compression | Accept-Encoding |
| Header-based versioning | Accept (if content negotiation is used) |
| CORS with different origins | Origin |
Issue: Vary: Authorization is a poor choice for public CDNs. Each unique token creates a separate cache entry. If you need to cache authorized requests, use surrogate keys or cache only on the client side.
Cache-Control Directives for APIs
Cache-Control: public, max-age=300, s-maxage=600
-
public— can be cached on CDN/proxy -
private— only in browser/client -
max-age=N— TTL in seconds for the client -
s-maxage=N— TTL for shared caches (CDN), overrides max-age -
no-cache— always validate via conditional request -
no-store— never cache (sensitive data) -
must-revalidate— don't use stale cache -
stale-while-revalidate=N— serve stale response for N seconds while updating -
stale-if-error=N— serve stale response on upstream error
For financial data, personal profiles: Cache-Control: private, no-store.
For public catalog: Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600.
Cache Invalidation
ETag/Last-Modified don't help with invalidation—they only confirm freshness. To force a cache purge on a CDN:
# Cloudflare API
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
--data '{"files":["https://api.example.com/v1/products/42"]}'
Surrogate keys (Cloudflare Cache-Tag, Fastly Surrogate-Key):
Cache-Tag: product-42, category-electronics
Surrogate-Key: product-42 category-electronics
After updating a product, invalidate all URLs with the product-42 tag in one call.
Testing
# Check response headers
curl -I https://api.example.com/v1/products/42
# Conditional request with ETag
curl -H 'If-None-Match: "abc123"' https://api.example.com/v1/products/42
# Check X-Cache from CDN
curl -v https://api.example.com/v1/products/42 2>&1 | grep -i 'x-cache\|age\|etag\|cache-control'
Timelines
Adding ETag + Last-Modified to an existing API: 2–4 days (implementation + tests + documentation). Full strategy with Vary, Cache-Control by resource type, CDN invalidation, and surrogate keys: 1–2 weeks.







