
September 12, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
Every production site pays twice for the same bytes when Cache-Control, ETag, and conditional requests are missing or misconfigured. Browsers, CDNs, and API clients re-download full responses even when nothing changed. That wastes bandwidth on slow Nepal mobile networks and adds load to your origin. On a REST API project, correct HTTP caching is architecture—not a polish step. This guide covers the headers, validators, and request flow you need in Laravel, Nginx, Apache, and at the CDN edge.
What Are Cache-Control Headers and How Do They Work?
Cache-Control is an HTTP response header. It tells every cache in the chain what to do with a response. That chain runs from the browser through a CDN to your reverse proxy. Without it, caches guess—and they often guess wrong.
The header uses comma-separated directives. Each directive is a rule, not a suggestion. A CDN will obey public, max-age=3600 literally. It will store the response for one hour and serve it without hitting your origin.
Core Cache-Control Directives
- public — Any cache may store the response, including shared CDN caches.
- private — Only the end-user browser may cache; shared caches must not.
- no-store — Do not persist the response anywhere. Use this for auth tokens and personal data.
- no-cache — Caches may store the response but must revalidate with the origin before reuse.
- max-age=N — Fresh for N seconds. During that window, caches serve without contacting origin.
- s-maxage=N — Same as max-age but applies only to shared caches like Cloudflare or Fastly.
- must-revalidate — Once stale, caches must not serve without revalidation.
- immutable — Content will never change during max-age. Ideal for Vite 8.x hashed assets.
A common mistake is setting Cache-Control: no-cache when you mean no-store. No-cache still stores the response. It just forces revalidation on every use. No-store prevents storage entirely. For session cookies or payment callbacks, no-store is the safe choice.
On static assets from a Vite 8.x build, I use long max-age with immutable. The filename hash changes on every deploy. There is no stale risk. HTML pages get short max-age or no-cache with ETag validation instead.
Example Nginx Configuration
# /etc/nginx/sites-available/example.conf
location ~* \.(js|css|woff2|webp)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
location / {
add_header Cache-Control "no-cache, must-revalidate";
} This split is standard on sites I deploy with Linux and Nginx administration. Hashed assets cache aggressively. Dynamic HTML revalidates every time.
How Do ETag Headers Enable Conditional Requests?
An ETag is an opaque validator string the server attaches to a response. It represents a specific version of a resource. When the client already has a copy, it sends that ETag back in an If-None-Match request header.
The server compares the client's ETag against the current version. If they match, the content is unchanged. The server returns 304 Not Modified with an empty body. The client reuses its cached copy. Bandwidth drops to a few hundred bytes.
Last-Modified works the same way with dates. The client sends If-Modified-Since. The server checks whether the resource changed after that timestamp. ETags are usually more precise because they can reflect content hash, not just file mtime.
Per MDN's ETag documentation, the header value is wrapped in double quotes. Weak validators use a W/ prefix. More on that distinction below.
Generating ETags in Laravel
Laravel 13 ships middleware that handles this cleanly. The framework can compute ETags from response content automatically.
<?php
// bootstrap/app.php — Laravel 13
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\Illuminate\Http\Middleware\SetCacheHeaders::class);
})
// routes/api.php
Route::get('/products/{id}', [ProductController::class, 'show'])
->middleware('cache.headers:public;max_age=3600;etag'); For manual control on a production Laravel application, hash the serialised payload:
public function show(Product $product)
{
$payload = $product->load('category')->toJson();
$etag = '"' . md5($payload) . '"';
if (request()->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
return response($payload)
->header('ETag', $etag)
->header('Cache-Control', 'public, max-age=300');
} I prefer content-hash ETags over filesystem mtime on dynamic API responses. Mtime breaks when you touch unrelated columns or run migrations. A hash of the serialised JSON reflects what the client actually receives. See also our dedicated guide on API caching with ETag and Last-Modified.
What Is the Difference Between Strong and Weak ETags?
Strong ETags mean byte-for-byte identity. Two responses with the same strong ETag are identical at the octet level. Weak ETags (prefixed with W/) mean semantic equivalence. The bytes may differ slightly but the meaning is the same.
Range requests require strong ETags. If you serve partial content with Accept-Ranges: bytes, weak ETags can produce incorrect 206 responses. For JSON APIs and HTML pages, weak ETags are usually fine.
| Validator Type | Format Example | Comparison Rule | Best Use Case |
|---|---|---|---|
| Strong ETag | "abc123" | Byte-identical match required | Static files, range requests, binary downloads |
| Weak ETag | W/"abc123" | Semantically equivalent | JSON APIs, gzip-compressed variants, CMS pages |
| Last-Modified | Sat, 12 Sep 2026 10:00:00 GMT | Second-level timestamp | Simple file serving, legacy clients |
| Both ETag + Last-Modified | Either header suffices | Client chooses which to send | Maximum client compatibility |
Nginx generates strong ETags from content by default. Apache mod_etag does the same. When gzip compression sits in the chain, the ETag often changes between compressed and uncompressed variants. That is why weak ETags exist—they let caches treat both forms as one resource.
A gotcha I have hit on client projects: never strip ETags at the CDN unless you replace them. Some CDNs remove ETag headers by default to avoid conflicts with their own caching logic. Check your provider settings. Cloudflare passes ETags through on most plans, but custom cache rules can override them. Our post on Cloudflare cache bypass for API endpoints covers edge-case routing.
How Do You Implement Conditional Requests in Laravel and Symfony?
Application-level caching and HTTP conditional requests solve different problems. Redis stores computed query results inside your app. HTTP validators tell external caches whether to reuse a response. You need both on high-traffic APIs.
Laravel Response Macros and Middleware
Beyond the built-in cache.headers middleware, Laravel's Response::macro pattern keeps controllers thin:
// app/Providers/AppServiceProvider.php
Response::macro('withEtag', function ($content, int $maxAge = 300) {
$etag = '"' . hash('xxh3', is_string($content) ? $content : json_encode($content)) . '"';
if (request()->header('If-None-Match') === $etag) {
return response('', 304)->withHeaders([
'ETag' => $etag,
'Cache-Control' => "public, max-age={$maxAge}",
]);
}
return response($content)->withHeaders([
'ETag' => $etag,
'Cache-Control' => "public, max-age={$maxAge}",
]);
}); Pair this with Laravel cache tags with Redis for server-side invalidation. When a product updates, flush the Redis key and bump the ETag on the next request. The two layers stay in sync.
Symfony 8.1 HttpCache
Symfony's built-in HTTP cache kernel handles conditional requests at the framework edge. Enable it in public/index.php for read-heavy Symfony apps:
use Symfony\Component\HttpKernel\HttpCache\Esi;
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
use Symfony\Component\HttpKernel\HttpCache\Store;
$kernel = new HttpCache($kernel, new Store(__DIR__.'/../var/cache/http_cache')); Symfony generates ETags from response content automatically when you call $response->setEtag($hash) or $response->isNotModified($request). The latter checks both If-None-Match and If-Modified-Since in one call. Details live in the Symfony HTTP cache documentation.
WordPress and WooCommerce 11.1
WordPress 7.1 sets conservative defaults. Most dynamic pages send no-cache via PHP headers. For WooCommerce product APIs or headless setups, add explicit headers in your theme or plugin:
add_action('rest_pre_serve_request', function ($served, $result, $request) {
if ($request->get_route() === '/wc/v3/products') {
header('Cache-Control: public, max-age=600');
$json = wp_json_encode($result);
header('ETag: "' . md5($json) . '"');
}
return $served;
}, 10, 3); Object caching with Redis is separate. See our guide on WordPress object cache with Redis for the application layer.
How Should You Configure CDN and Reverse Proxy Caching?
CDNs respect Cache-Control from origin unless a page rule overrides it. Your origin must send correct headers first. Fixing cache behaviour at the CDN alone is a losing battle.
Testing Conditional Requests
Use curl to verify the full round trip before trusting browser DevTools:
- Fetch the resource and capture the ETag from response headers.
- Send a second request with
If-None-Matchset to that ETag. - Confirm you receive HTTP 304 with an empty body.
- Change the resource and confirm the ETag changes and you get HTTP 200.
# Step 1: initial request
curl -sI https://example.com/api/v1/rates
# Step 2: conditional request
curl -sI -H 'If-None-Match: "abc123def456"' \
https://example.com/api/v1/rates
# Expected: HTTP/2 304 + ETag header echoed back Paste JSON responses into the JSON formatter tool to compare payloads when debugging ETag mismatches. A single trailing space in serialised JSON produces a different hash.
Cache Invalidation Patterns
Time-based expiry alone fails when content changes unpredictably. Combine max-age with active invalidation:
- Purge API — Call your CDN's purge endpoint when content updates. Cloudflare, Fastly, and Bunny all expose one.
- Cache tags — Some CDNs support tag-based purge. Tag responses by content type or tenant ID.
- ETag bump — Change the ETag source when data changes. No CDN purge needed if max-age is short.
- Versioned URLs — Append
?v=20260912to assets after deploy. Simple and reliable.
Read cache invalidation patterns for the full taxonomy. Also study preventing cache stampede when many clients hit a stale resource at once.
Vary Header and Content Negotiation
When your API returns different content based on Accept-Language or Accept-Encoding, add a Vary header. Caches treat each variant as a separate entry. Without Vary, a gzip client might receive an uncompressed cached response.
Cache-Control: public, max-age=3600
ETag: "xyz789"
Vary: Accept-Encoding, Accept-Language On a legal-tech portal I built, Nepali and English page variants each needed distinct cache keys. The Vary header prevented English users from receiving Nepali cached HTML. That kind of bug is silent and hard to diagnose without explicit Vary rules.
What HTTP Status Codes Apply to Conditional Requests?
Conditional requests map to a small set of status codes defined in RFC 7232. Know them before debugging production cache issues.
| Status | When It Occurs | Body | Client Action |
|---|---|---|---|
| 200 OK | Resource changed or no validator sent | Full response | Replace cached copy |
| 304 Not Modified | ETag or date matches current version | Empty | Reuse cached copy |
| 412 Precondition Failed | If-Match header on PUT/PATCH does not match | Error detail | Refetch and retry with new ETag |
412 is the write-side mirror of 304. Optimistic concurrency control uses If-Match on updates. If another client changed the resource first, the ETag no longer matches and the server rejects the write. This pattern appears in REST APIs for inventory and booking systems like those on Adventure Third Pole Trek.
Common Production Mistakes
These issues appear repeatedly on sites I audit during testing and optimization engagements:
- ETag on personalised responses — Never cache user-specific JSON with public directives. Use private or no-store instead.
- Conflicting headers — Expires and Cache-Control both present with different values. Modern clients prefer Cache-Control. Remove Expires.
- PHP session cookies on static assets — PHP sends Set-Cookie on every request by default. CDNs refuse to cache responses with Set-Cookie.
- Query string blindness — Some proxies ignore URL parameters when caching. Configure cache keys to include relevant query args.
- Stale ETag after deploy — Opcache serves old PHP that generates old ETags. Reload PHP-FPM after deploy. I hit this on Deployer 7 releases regularly.
For rate-limited public APIs, combine ETag caching with token buckets. Cached 304 responses still count as requests at some gateways. Our guide on API rate limiting and abuse prevention covers that interaction.
Key Takeaways
- Set Cache-Control per resource type: immutable max-age for hashed assets, short max-age plus ETag for APIs, no-store for auth and payment flows.
- Generate ETags from content hashes on dynamic responses, not filesystem mtimes, to avoid false 304 matches after unrelated DB writes.
- Test with curl using If-None-Match before assuming browser or CDN caching works correctly in production.
- Reload PHP-FPM after every deploy so opcache does not serve stale code that produces outdated ETag validators.
- Use the Vary header whenever content differs by Accept-Encoding or Accept-Language so caches store separate variants.
- Pair HTTP validators with application-level Redis caching and explicit CDN purge on content updates for a complete strategy.
People Also Ask
What is the difference between Cache-Control no-cache and no-store?
No-cache allows storage but requires revalidation before each use. The browser or CDN keeps the response on disk. No-store forbids all storage. Use no-store for passwords, session data, and PCI-scoped payment responses. Use no-cache when you want a cached copy available but always want the origin to confirm freshness via ETag.
Does a 304 response include a body?
No. A 304 Not Modified response has an empty body by specification. The client must reuse its locally stored copy from the earlier 200 response. The server may echo Cache-Control and ETag headers in the 304 to update metadata without resending content.
Can CDNs cache responses with Set-Cookie headers?
Most CDNs refuse to cache any response that includes Set-Cookie. That is why PHP applications accidentally mark every page uncacheable when sessions start on static asset requests. Disable session auto-start for asset routes or serve static files directly from Nginx to fix this.
Should APIs use ETag or Last-Modified?
Prefer ETag for JSON APIs because it reflects actual content, not file timestamps. Last-Modified works for static files served directly by the web server. Sending both headers costs nothing and maximises compatibility with older HTTP clients and some corporate proxies.
Ship Faster Responses With Correct HTTP Caching
Cache-Control, ETag, and conditional requests are the cheapest performance win available. No Redis cluster required. No frontend rewrite. Just correct headers on every response, tested with curl, and validated after each deploy. On Nepal mobile networks where megabytes still cost real money, 304 responses matter to real users—not just Lighthouse scores.
If your Laravel API, WooCommerce store, or legal-tech portal serves stale data or burns origin CPU on repeat downloads, the fix usually takes hours—not weeks. Start with one high-traffic endpoint, add ETag validation, measure bandwidth before and after, then roll the pattern across your routes.
Need help auditing cache headers across your stack? Speed optimization services and web development cover header strategy through CDN configuration. See live results on Court Marriage In Nepal and other production sites in the portfolio. Contact us to review your current setup.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

