Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Cache-Control, ETag, and Conditional Requests

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.
Cache-Control Flow Across the HTTP ChainBrowserprivate cacheCDN Edges-maxageNginxproxy_cacheOriginLaravel 13Response: Cache-Control: public, max-age=3600, s-maxage=86400Browser fresh 1h · CDN fresh 24h · then revalidateFresh Period: serve from cache, zero origin hitsStale Period: conditional request with ETag or Last-Modified
Cache-Control directives govern each hop in the HTTP caching chain from browser to Laravel origin

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.

ETag Conditional Request SequenceClient (cached)Origin ServerGET /api/products/42If-None-Match: "a1b2c3d4"Server compares ETag against current resource hash304 Not Modified (empty body)ETag: "a1b2c3d4" · Cache-Control echoedClient reuses cached body — saves 99% bandwidthOrigin still validates freshness on every stale hit
ETag conditional requests return 304 Not Modified when the cached version is still valid

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 TypeFormat ExampleComparison RuleBest Use Case
Strong ETag"abc123"Byte-identical match requiredStatic files, range requests, binary downloads
Weak ETagW/"abc123"Semantically equivalentJSON APIs, gzip-compressed variants, CMS pages
Last-ModifiedSat, 12 Sep 2026 10:00:00 GMTSecond-level timestampSimple file serving, legacy clients
Both ETag + Last-ModifiedEither header sufficesClient chooses which to sendMaximum 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.

Cache Strategy Decision TreeWhat content type?Hashed staticJS, CSS, fontsPublic APIJSON, XML feedsPrivate dataAuth, paymentsmax-age=31536000immutable, strong ETagmax-age=300 + ETagweak ETag, s-maxageCache-Control: no-storeno ETag neededMatch strategy to content sensitivity and change frequencyRevisit after deploys and when data models change
Decision tree for selecting Cache-Control directives and ETag validators by resource type

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:

  1. Fetch the resource and capture the ETag from response headers.
  2. Send a second request with If-None-Match set to that ETag.
  3. Confirm you receive HTTP 304 with an empty body.
  4. 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=20260912 to 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.

Bandwidth: Full Response vs 304 ConditionalWithout ETag200 OK · 48 KB body200 OK · 48 KB body200 OK · 48 KB body3 requests = 144 KBWith ETag200 OK · 48 KB body304 · 0.3 KB304 · 0.3 KB3 requests ≈ 49 KB66% bandwidth saved on repeat fetchesOrigin CPU still runs ETag comparison — cheap vs serialisation
ETag conditional requests dramatically reduce bandwidth on repeat API fetches compared to full 200 responses

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.

StatusWhen It OccursBodyClient Action
200 OKResource changed or no validator sentFull responseReplace cached copy
304 Not ModifiedETag or date matches current versionEmptyReuse cached copy
412 Precondition FailedIf-Match header on PUT/PATCH does not matchError detailRefetch 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.
Deploy Flow: ETag Consistency After ReleaseGit PushDeployer 7SymlinkPHP-FPMLiveSkip FPM reload → stale opcache → wrong ETagClients get 304 for outdated contentReload FPM → fresh code → new ETag hashConditional requests return correct 200 or 304Include sudo systemctl reload php8.5-fpm in deploy hooks
PHP-FPM reload after deploy ensures ETag hashes reflect the latest Laravel application code

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.

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

Cache-Control is an HTTP response header that tells every cache in the chain—from the browser through a CDN to your reverse proxy—what to do with a response. It uses comma-separated directives that caches treat as rules, not suggestions. A CDN will obey public and max-age=3600 literally, storing the response for one hour without hitting your origin. Without Cache-Control, caches guess, and they often guess wrong.

No-cache still stores the response but forces revalidation with the origin before every reuse. No-store prevents persistence entirely—nothing is written to browser or CDN storage. A common production mistake is setting no-cache when you mean no-store. For session cookies, auth tokens, payment callbacks, and personal data, no-store is the safe choice. No-cache suits HTML or API responses you want cached locally but verified on each request.

An ETag is an opaque validator string the server attaches to a response, representing a specific version of a resource. Per MDN, the value is wrapped in double quotes. When the client already holds a copy, it sends that ETag in an If-None-Match request header. The server compares it against the current version. If they match, content is unchanged and the server returns 304 Not Modified with an empty body.

The server returns 304 Not Modified. The response body is empty and bandwidth drops to a few hundred bytes. The client reuses its stored copy.

Conditional requests let clients ask whether a cached copy is still current before downloading again. With ETags, the client sends If-None-Match carrying the ETag from its last response. With Last-Modified, it sends If-Modified-Since with a timestamp. The server checks the validator against the current resource. A match yields 304 Not Modified and an empty body. A mismatch or missing validator yields 200 OK with the full response.

Strong ETags require byte-for-byte identity—two responses sharing one strong ETag are identical at the octet level. Weak ETags use a W/ prefix and mean semantic equivalence; bytes may differ but meaning is the same. Range requests with Accept-Ranges: bytes require strong ETags—weak validators can produce incorrect 206 responses. Nginx and Apache generate strong ETags from content by default. When gzip compression sits in the chain, weak ETags let caches treat compressed and uncompressed variants as one resource.

Laravel 13 ships SetCacheHeaders middleware. Register it in bootstrap/app.php, then apply route middleware like cache.headers:public;max_age=3600;etag. For manual control, hash the serialised JSON payload with md5, compare request()->header('If-None-Match') against the quoted ETag, return 304 on match, otherwise 200 with ETag and Cache-Control headers. A Response::macro using hash('xxh3', ...) keeps controllers thin. Pair HTTP validators with Redis cache tags so server-side invalidation and ETag bumps stay aligned when data changes.

Split caching by resource type. For hashed JS, CSS, woff2, and webp files from a Vite 8.x build, set expires 1y and Cache-Control public, max-age=31536000, immutable—the filename hash changes on every deploy, so stale risk is minimal. For dynamic HTML at location /, use Cache-Control no-cache, must-revalidate so browsers revalidate each visit. Turn access_log off on static assets to reduce I/O. This pattern is standard on sites deployed with Linux and Nginx administration.

On dynamic API responses, content-hash ETags are preferable. Hash the serialised JSON the client actually receives—md5 or xxh3 both work. Filesystem mtime breaks when you touch unrelated database columns or run migrations, producing false 304 matches or unnecessary full downloads. I have seen this on production Laravel applications serving product or rate endpoints. Strong ETags from Nginx or Apache suit static files; application code should hash the response payload for accuracy.

They solve different problems and you often need both on high-traffic APIs. Redis stores computed query results inside your application—faster database access on cache hits. HTTP validators like ETag and Cache-Control tell external caches—browsers, CDNs, API clients—whether to reuse a response without contacting origin. When a product updates, flush the Redis key and let the next request bump the ETag. WordPress object caching with Redis is similarly separate from REST Cache-Control headers you add in a theme or plugin.

Use curl to verify the full round trip—browser DevTools alone can mislead. Step one: curl -sI the URL and capture the ETag from response headers. Step two: resend with -H 'If-None-Match: "your-etag"' and confirm HTTP 304 with an empty body and the ETag echoed back. Change the resource and confirm the ETag changes and you receive HTTP 200. When debugging mismatches, compare serialised JSON carefully—a single trailing space produces a different hash.

CDNs respect Cache-Control from origin unless a page rule or custom cache rule overrides it. Your origin must send correct headers first—fixing cache behaviour at the CDN alone is a losing battle. Some CDNs strip ETag headers by default to avoid conflicts with their own caching logic; check provider settings before assuming validators pass through. Cloudflare passes ETags on most plans, but custom rules can override them. Combine short max-age with ETag bumping when you cannot rely on purge APIs.

Add Vary when your API or site returns different content based on request headers like Accept-Encoding or Accept-Language. Caches treat each variant as a separate entry. Without Vary, a gzip client might receive an uncompressed cached response, or an English user might receive Nepali HTML. On a legal-tech portal with Nepali and English page variants, explicit Vary rules prevented silent cross-language cache pollution. Typical pairing: Cache-Control public, max-age=3600, plus Vary: Accept-Encoding, Accept-Language alongside your ETag.

HTTP 412 is the write-side mirror of 304 Not Modified. It applies when a client sends If-Match on PUT or PATCH and the ETag no longer matches the server's current version—another client changed the resource first. The server rejects the write with error detail; the client should refetch and retry with the new ETag. This optimistic concurrency pattern appears in REST APIs for inventory and booking systems where simultaneous updates must not silently overwrite each other.

Never attach public Cache-Control and ETags to personalised JSON—use private or no-store instead. Do not send both Expires and conflicting Cache-Control; modern clients prefer Cache-Control. PHP session cookies on static asset requests prevent CDN caching because Set-Cookie blocks storage. Some proxies ignore query strings when building cache keys—configure keys to include relevant args. After Deployer 7 releases, reload PHP-FPM so opcache does not serve old PHP generating stale ETags. Never strip ETags at the CDN without replacing them. Cached 304 responses may still count toward API rate limits at some gateways.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: