Setting up HTTP caching: Cache-Control and ETag
Correct caching headers allow browsers and CDN to store resources without repeated server requests. Static assets (JS, CSS, fonts) with proper cache load instantly on repeat visits.
Cache-Control directives
Cache-Control: public, max-age=31536000, immutable
| Directive | Value |
|---|---|
public |
Cache in browser and on proxy/CDN |
private |
Browser only (not on CDN) |
no-cache |
Always validate freshness with server |
no-store |
Never cache |
max-age=N |
Cache for N seconds |
s-maxage=N |
For CDN (overrides max-age) |
immutable |
File won't change—don't revalidate even on F5 |
must-revalidate |
After max-age—mandatory revalidation |
Strategy by resource type
# Nginx configuration
# Static assets with hash in name (Vite, Webpack)
# app.a1b2c3d4.js — hash changes when file changes
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary Accept-Encoding;
}
# Fonts—also immutable (change URL on update)
location ~* \.(woff2|woff|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin *;
}
# Images—long cache, no immutable
location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# HTML—short cache or no-cache
location ~* \.html$ {
expires 5m;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# API—don't cache
location /api/ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma no-cache;
}
ETag — conditional requests
ETag allows checking cache freshness without sending entire file:
# First request
GET /images/logo.png
Response: 200 + ETag: "abc123" + Last-Modified: Mon, 15 Mar 2024
# Repeat request
GET /images/logo.png
If-None-Match: "abc123"
Response: 304 Not Modified (body not sent—headers only)
# Nginx: ETag enabled by default
etag on;
# Last-Modified also enabled by default
Laravel: API response caching
// Cache at HTTP response level for API
public function products(Request $request): JsonResponse
{
$products = Cache::remember('api:products', 300, fn() =>
Product::with('category')->where('is_active', true)->get()
);
$etag = md5($products->pluck('updated_at')->max());
if ($request->header('If-None-Match') === $etag) {
return response()->noContent(304);
}
return response()->json($products)
->header('Cache-Control', 'public, max-age=300')
->header('ETag', $etag);
}
Cache invalidation via file versioning
Vite automatically adds hash to filenames at build:
app.js → app.a1b2c3d4.js
app.css → app.9f8e7d6c.css
Code change → new hash → browser loads new file, despite immutable.
Vary: proper content negotiation caching
# Different caches for gzip/brotli versions
location ~* \.(js|css)$ {
add_header Vary Accept-Encoding;
gzip_static on;
brotli_static on;
}
Without Vary: Accept-Encoding, CDN may serve gzip version to browser not supporting gzip.
Stale-While-Revalidate
# Serve cached response, revalidate in background
add_header Cache-Control "public, max-age=300, stale-while-revalidate=3600";
User gets instant cached response; browser revalidates in background for next visit.
Setup time: several hours for complete Nginx setup.







