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.

HTTP Caching Headers Explained

By Kokil Thapa | Last reviewed: September 2026

Every slow page reload and every unnecessary database hit often traces back to missing or wrong cache directives. HTTP caching headers explained properly tells you how browsers, CDNs, and reverse proxies decide whether to reuse a stored response or fetch fresh bytes from your origin. On production Laravel apps and web platforms I maintain, cache headers sit alongside application caching and database tuning as a first-class performance lever. This guide walks through each header, shows real config you can paste, and covers the mistakes I see most often in audits.

What Are HTTP Caching Headers and Why Do They Matter?

HTTP caching headers are metadata attached to a response. They do not change the body. They change how long that body may be reused without contacting your server again. A browser cache, a CDN edge node, and an upstream reverse proxy all read the same headers—but they interpret shared versus private rules differently.

Without explicit headers, many clients apply heuristic caching. That guesswork produces stale CSS after a deploy or fresh HTML on every visit. Both outcomes hurt user experience and SEO. Google treats page speed as a ranking signal, and repeat visitors on a brochure site or content-heavy legal portal feel latency immediately when assets re-download on every page view.

The modern specification is RFC 9111 (HTTP Caching), which replaced older RFC 7234 guidance. For day-to-day work, MDN's HTTP caching documentation remains the fastest reference when you need directive semantics at a glance.

HTTP Caching Headers Request FlowBrowserPrivate cacheCDN EdgeShared cacheOriginLaravel / PHPResponse Headers Read by Every LayerCache-Control: public, max-age=3600ETag: "abc123" | Last-Modified: Tue, 01 Sep 2026Age header shows time in shared cache304 Not Modified saves full body transfer
HTTP caching headers explained as a three-tier flow: browser, CDN, and origin each honor Cache-Control and validation headers.

Caching operates at two levels that engineers often conflate. Freshness means the cached copy is still within its max-age window. Validation means the cache expired but the client asks the server whether the content changed. Validation returns HTTP 304 with an empty body when nothing changed. That pattern pairs naturally with ETag and Last-Modified strategies for APIs and static assets alike.

Freshness versus validation in plain terms

Think of max-age as a timer on the shelf. Until it expires, no network call is required. After expiry, the client may still keep the old copy while it revalidates. If the server confirms the ETag matches, you get speed without serving outdated HTML to logged-in users.

How Does Cache-Control Work for Browser and CDN Caching?

Cache-Control is the primary HTTP caching header on modern stacks. It replaces most legacy Expires usage, though both can coexist. Directives are comma-separated. Order rarely matters; conflicting directives follow RFC precedence rules.

Common directives and their effect:

  • max-age=31536000 — fresh for one year (typical for fingerprinted assets).
  • s-maxage=86400 — shared cache TTL separate from browser TTL.
  • public — any cache may store the response.
  • private — only the end-user browser may cache; CDNs should not.
  • no-cache — cache may store but must revalidate before use.
  • no-store — do not persist the response anywhere.
  • immutable — hint that content will not change during max-age.
  • stale-while-revalidate=60 — serve stale up to 60 seconds while fetching fresh.

On a WooCommerce or Laravel storefront like Petals Qatar, product images get long max-age values because filenames include content hashes. Cart and checkout pages get no-store because they contain session-specific pricing.

Cache-Control Decision TreeSensitive user data?Yesno-storeCheckout, adminNoFingerprinted asset?Yespublic, immutablemax-age=31536000NoHTML / API JSONprivate or short max-ageMatch header policy to content sensitivity and change frequency
HTTP caching headers explained: choose Cache-Control directives based on sensitivity and whether the URL is content-addressed.

Example Cache-Control values by asset type

Resource typeRecommended Cache-ControlWhy
Versioned JS/CSS (app.a1b2c3.js)public, max-age=31536000, immutableFilename changes on deploy; safe to cache forever
HTML pagesno-cache or short max-age=300Content updates frequently; revalidation preferred
Authenticated APIprivate, no-storePrevents shared caches from leaking user data
Public API list endpointpublic, max-age=60, s-maxage=300CDN absorbs traffic; browser gets shorter TTL
User uploads / avatarspublic, max-age=86400Changes occasionally; ETag validation helps

For deeper layering strategies, see the guide on caching strategies in production and how HTTP headers complement Redis or application caches.

How Do ETag and Last-Modified Headers Enable Conditional Requests?

When freshness expires, caches send conditional requests. If-None-Match carries the stored ETag. If-Modified-Since carries the stored Last-Modified timestamp. The server compares them and returns either 200 with a full body or 304 Not Modified with updated cache metadata.

ETags are opaque validators. They may be strong (byte-identical) or weak (prefixed with W/). Strong ETags suit static files. Weak ETags suit compressed variants where bytes differ but meaning does not. Last-Modified is simpler but less precise when multiple updates happen within one second.

ETag Conditional Request SequenceClientOriginGET /styles.css If-None-Match: "v3"304 Not Modified (empty body)Client reuses cached bodyBandwidth saved: no CSS re-downloadLatency drops on repeat visitsCore Web Vitals improve on static assets
HTTP caching headers explained: ETag validation returns 304 when content is unchanged, preserving bandwidth and TTFB.

Testing conditional requests with curl

curl -I https://example.com/assets/app.css

HTTP/2 200
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"
last-modified: Mon, 01 Sep 2026 08:00:00 GMT

curl -I https://example.com/assets/app.css \
  -H 'If-None-Match: "a1b2c3d4"'

HTTP/2 304
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"

Laravel can emit ETags via middleware or packages. Application-level cache in Redis does not replace HTTP headers—they solve different problems. See Laravel config, route, and view caching for the full stack picture, and Redis caching for Laravel when response assembly is expensive.

What Is the Difference Between public, private, and no-store Directives?

These three directives cause the most production incidents because their names sound interchangeable. They are not.

public allows shared caches—CDNs, corporate proxies—to store the response. Use it for static assets and anonymous HTML that is identical for all visitors.

private restricts storage to a single user's browser. Shared intermediaries must not use the response to serve other clients. User dashboards and personalised JSON belong here.

no-store forbids persisting the response in any cache. Banking flows, admin panels, and health data endpoints need this. It is stronger than no-cache, which still permits storage with mandatory revalidation.

no-cache surprises people. It does not mean "do not cache." It means "always revalidate with the origin before serving from cache." That is appropriate for HTML that changes often but where 304 responses still save bandwidth.

On legal-tech portals I have shipped, document download links often use short private, max-age=0, must-revalidate because the URL may be time-limited even when the file bytes are static. Payment callback pages always get no-store.

How Do You Set HTTP Caching Headers in Laravel, Nginx, and Apache?

Headers can originate from the web server, middleware, or framework response objects. Pick one authoritative layer per resource type. Duplicated or conflicting headers confuse CDNs.

Laravel 12 / 13 middleware example

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class CacheStaticAssets
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        if ($request->is('build/*', 'images/*', 'fonts/*')) {
            $response->headers->set(
                'Cache-Control',
                'public, max-age=31536000, immutable'
            );
        }

        return $response;
    }
}

Register the middleware in bootstrap/app.php for Laravel 12+. For Vite-built assets, fingerprinted files under /build/assets/ should never receive no-cache from a blanket HTML policy.

Nginx location blocks

location ~* \.(js|css|woff2|png|jpg|webp)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}

location / {
    add_header Cache-Control "no-cache";
}

When Nginx sits behind Cloudflare or another CDN, align s-maxage with your purge workflow. The article on configuring a caching reverse proxy covers Varnish and similar setups in more detail.

Apache mod_headers

<FilesMatch "\.(js|css|ico|svg|woff2)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

<FilesMatch "\.(php|html)$">
    Header set Cache-Control "no-cache"
</FilesMatch>

I deploy many sites on Ubuntu with Apache and PHP-FPM 8.3 or 8.4. After changing header config, reload the web server and verify with curl—not the browser devtools alone. DevTools can show disk cache behaviour that masks missing CDN headers.

  1. Identify asset classes: static, semi-static, dynamic, sensitive.
  2. Set server-level rules for file extensions first.
  3. Add Laravel middleware only where responses are dynamic but cacheable.
  4. Configure CDN cache rules to respect origin Cache-Control.
  5. Purge CDN on deploy when HTML references non-fingerprinted assets.
  6. Monitor cache hit ratio and 304 rate in logs or CDN analytics.

For high-traffic patterns, read caching strategies for high-traffic sites and reverse proxy caching with Varnish. TLS termination does not alter cache semantics, but ensure redirects are consistent—see HTTPS setup with Let's Encrypt if mixed-content or redirect loops interfere with cache keys.

What Are Common HTTP Caching Header Mistakes That Break Performance?

The same bugs appear across audits regardless of stack. Fixing headers is often cheaper than adding server capacity.

  • Caching HTML with long max-age — visitors see old navigation or stale CSRF tokens after deploy.
  • no-store on all assets — every image and font hits origin; Core Web Vitals suffer.
  • Missing Vary header — gzip and Brotli variants collide; clients get wrong encoding.
  • ETag from inode on clustered servers — validators differ per node; 304 breaks. Prefer content hash ETags.
  • Set-Cookie on static assets — many CDNs refuse to cache responses with cookies.
  • Ignoring s-maxage — browser TTL and CDN TTL need different values on list pages.
Misconfigured vs Correct Cache HeadersBefore (Broken)All files: no-storeOrigin hits: 100%LCP: 3.8sCDN hit ratio: 0%Deploy: users OKCost: high bandwidthParanoia defaults hurt speedAfter (Tuned)Assets: 1y immutableHTML: no-cacheLCP: 1.6sCDN hit ratio: 92%API: private + ETagCost: lower origin loadHeaders match content type
HTTP caching headers explained: tiered policies cut origin load and improve LCP compared to blanket no-store defaults.

Use browser Network tabs and curl -I together. The JSON formatter tool helps inspect API responses when debugging cache-related response bodies alongside headers. For performance audits tied to search visibility, technical SEO work and speed optimization should include a header review in the first hour.

HTTP/2 and HTTP/3 multiplexing change connection economics but not cache semantics. Multiplexed requests still honor the same validators—see HTTP/2 vs HTTP/3 and QUIC for transport-level context. Improving web performance with caching ties headers to broader architecture choices.

On shared hosting common in Nepal (Rs 3,000–8,000/year, ~USD 22–60), you may lack CDN control. Even then, correct Apache or Nginx headers reduce repeat bytes. Managed CDN add-ons often cost Rs 1,500/month (~USD 11) and pay back quickly on media-heavy sites.

Application caches—Redis, Memcached, Laravel view cache—sit behind HTTP. They speed up origin generation. HTTP headers speed up delivery after generation. You need both on busy platforms. Testing and optimization should measure before-and-after TTFB and transfer size, not just Lighthouse scores in throttled lab mode.

For infrastructure ownership, Linux system administration covers reload procedures and log formats that expose cache status. The Age response header shows seconds an object lived in a shared cache—useful when debugging stale CDN content.

Key Takeaways

  • Cache-Control is the primary HTTP caching header—set tiered policies for assets, HTML, and authenticated routes.
  • Use public, max-age=31536000, immutable only for fingerprinted static files; never on HTML or session-specific pages.
  • ETag and Last-Modified enable 304 responses that save bandwidth after freshness expires.
  • no-store, private, and no-cache mean different things—mixing them up leaks data or kills performance.
  • Verify headers with curl -I at the CDN edge and origin; fix ETag generation on multi-server clusters.
  • HTTP caching headers complement Redis and Laravel caches—they do not replace each other.

People Also Ask

What is the difference between Cache-Control and Expires?

Cache-Control uses relative max-age seconds and supports modern directives like immutable and stale-while-revalidate. Expires sets an absolute date. Browsers honor Cache-Control when both are present. Prefer Cache-Control on new projects; keep Expires only for legacy clients if analytics show need.

How long should max-age be for CSS and JavaScript files?

Use one year (max-age=31536000) plus immutable when filenames include content hashes from Vite or Webpack. Without hashed names, use short TTLs or aggressive revalidation. A deploy that changes app.js without a hash forces every user to download again unless you purge CDN caches manually.

Do HTTP caching headers work with CDNs like Cloudflare?

Yes. CDNs are shared caches. They respect s-maxage, public, and private from origin unless overridden by CDN page rules. Purge API calls remain necessary when you cache non-fingerprinted URLs. Always test with curl against the CDN hostname, not only your origin IP.

Should API responses include caching headers?

Public read-only GET endpoints benefit from short max-age or ETag validation. Authenticated endpoints should use private, no-store unless you implement careful cache key separation. POST, PUT, and DELETE responses should not be cached. Align with API design practices documented in your OpenAPI spec so clients know what to expect.

Ship Faster Pages with Correct Cache Headers

Getting HTTP caching headers explained and implemented correctly is one of the highest-return changes on a live site. Long cache for hashed assets, revalidation for HTML, and no-store for sensitive flows— that three-tier model covers most Laravel, WordPress, and eCommerce stacks I work on. Audit your top twenty URLs this week, fix the outliers, and measure transfer size on repeat visits.

Need a header audit on a production app or CDN misconfiguration review? Contact us for a performance pass, or browse the portfolio for examples of optimised deployments. For ongoing tuning after launch, support and maintenance keeps cache policies aligned with each release.

Frequently Asked Questions

Response metadata such as Cache-Control, ETag, and Last-Modified that tell browsers, CDNs, and reverse proxies how long to store a resource and when to revalidate—without changing the response body.

Without explicit headers, clients guess with heuristic caching. That produces stale CSS after a deploy or fresh HTML on every visit—both hurt user experience. Google treats page speed as a ranking signal, and repeat visitors on content-heavy sites feel latency when assets re-download unnecessarily. HTTP caching headers explained properly gives you tiered control: long TTL for fingerprinted assets, revalidation for HTML, and no-store for sensitive flows. Fixing headers is often cheaper than adding server capacity, and it directly improves Core Web Vitals metrics like LCP when static assets stop hitting origin on every page view.

Cache-Control uses relative max-age seconds and supports modern directives like immutable, s-maxage, and stale-while-revalidate. Expires sets an absolute expiry date and is legacy for most new work. When both headers are present, browsers honor Cache-Control per RFC 9111 precedence rules. On production stacks I maintain, I prefer Cache-Control on all new projects. Keep Expires only if analytics show legacy clients still depend on it. For day-to-day directive semantics, MDN's HTTP caching documentation remains the fastest reference alongside the RFC 9111 specification that replaced older RFC 7234 guidance.

One year (max-age=31536000) plus immutable when Vite or Webpack fingerprints filenames with content hashes. Without hashed names, use short TTLs or aggressive revalidation instead.

These three directives cause the most production incidents because their names sound interchangeable. public allows shared caches—CDNs and corporate proxies—to store the response; use it for static assets and anonymous HTML identical for all visitors. private restricts storage to a single user's browser; shared intermediaries must not serve that response to other clients. User dashboards and personalised JSON belong here. no-store forbids persisting the response in any cache and is stronger than no-cache. Banking flows, admin panels, and payment callback pages always need no-store. On legal-tech portals I have shipped, document download links often use short private, max-age=0, must-revalidate because URLs may be time-limited.

It surprises many developers because it does not mean do not cache. It means the cache may store the response but must revalidate with the origin before serving it. That suits HTML pages that change often but where 304 Not Modified responses still save bandwidth after freshness expires. Contrast this with no-store, which forbids persistence entirely. For HTML on Laravel or WooCommerce storefronts, no-cache or a short max-age=300 with revalidation is typical. Cart and checkout pages need no-store instead because they contain session-specific pricing that must never sit in a shared cache.

When freshness expires, caches send conditional requests. If-None-Match carries the stored ETag; If-Modified-Since carries the stored Last-Modified timestamp. The server compares them and returns either 200 with a full body or 304 Not Modified with updated cache metadata and an empty body. ETags are opaque validators—strong ETags suit static files, weak ETags (prefixed W/) suit compressed variants where bytes differ but meaning does not. Last-Modified is simpler but less precise when multiple updates happen within one second. Test with curl -I and an If-None-Match header against your origin and CDN edge to confirm 304 behaviour before relying on it in production.

Headers can originate from middleware, the framework response object, or the web server—pick one authoritative layer per resource type. Register middleware in bootstrap/app.php for Laravel 12+. A practical pattern applies public, max-age=31536000, immutable to fingerprinted Vite assets under /build/assets/, while dynamic routes get no-cache or private directives. Laravel can emit ETags via middleware or packages, but application-level Redis cache does not replace HTTP headers—they solve different problems. After changing header config, reload the web server and verify with curl, not browser DevTools alone, because DevTools can mask missing CDN headers with disk cache behaviour.

Set server-level rules for file extensions first, then add Laravel middleware only where responses are dynamic but cacheable. In Nginx, use location blocks: long expires and immutable Cache-Control for js, css, woff2, png, jpg, and webp; no-cache for the root HTML location. In Apache, use mod_headers FilesMatch blocks with the same tiered policy. I deploy many sites on Ubuntu with Apache and PHP-FPM 8.3 or 8.4—after changes, reload the web server and verify with curl -I. When Nginx sits behind Cloudflare or another CDN, align s-maxage with your purge workflow. Duplicated or conflicting headers from multiple layers confuse CDNs and produce unpredictable cache behaviour.

Yes. CDNs are shared caches that respect s-maxage, public, and private from origin unless overridden by CDN page rules. Purge API calls remain necessary when you cache non-fingerprinted URLs whose content changes without a filename change. Always test with curl against the CDN hostname, not only your origin IP, because edge behaviour differs from what your application emits locally. The Age response header shows seconds an object lived in a shared cache—useful when debugging stale CDN content after a deploy. Configure CDN cache rules to respect origin Cache-Control rather than blanket overrides that ignore your tiered asset policies.

Public read-only GET endpoints benefit from short max-age or ETag validation—for example public, max-age=60, s-maxage=300 on list endpoints where the CDN absorbs traffic and the browser gets a shorter TTL. Authenticated endpoints should use private, no-store unless you implement careful cache key separation. POST, PUT, and DELETE responses should not be cached. Align caching behaviour with your OpenAPI spec so API clients know what to expect. On a production Laravel application, I treat authenticated JSON as private, no-store by default because shared caches leaking user data is far worse than a few extra origin requests.

Freshness means the cached copy is still within its max-age window—no network call required until the timer expires. Validation means the cache expired but the client asks the server whether content changed, using ETag or Last-Modified. If the server confirms nothing changed, you get HTTP 304 with an empty body—speed without serving outdated HTML to logged-in users. Think of max-age as a timer on the shelf. After expiry, the client may still keep the old copy while it revalidates. This two-level model pairs naturally with tiered Cache-Control policies across static assets, semi-static uploads, and frequently updated HTML pages.

The same bugs appear across audits regardless of stack. Caching HTML with long max-age leaves visitors seeing old navigation or stale CSRF tokens after deploy. Applying no-store to all assets forces every image and font to hit origin, crushing Core Web Vitals. Missing Vary header causes gzip and Brotli variants to collide so clients receive wrong encoding. ETag from inode on clustered servers produces different validators per node, breaking 304 responses—prefer content hash ETags. Set-Cookie on static assets causes many CDNs to refuse caching. Ignoring s-maxage means browser TTL and CDN TTL stay identical when list pages need different values at each tier.

No—they complement each other. Application caches like Redis, Memcached, and Laravel view cache sit behind HTTP and speed up origin response generation when database queries or view assembly is expensive. HTTP headers speed up delivery after generation by letting browsers, CDNs, and reverse proxies reuse stored responses without contacting your server. You need both on busy platforms. A fast Redis layer still wastes bandwidth if every visitor re-downloads fingerprinted CSS because headers were missing. Treat cache headers alongside application caching and database tuning as a first-class performance lever, and measure before-and-after TTFB and transfer size—not just Lighthouse scores in throttled lab mode.

Managed CDN add-ons often cost Rs 1,500/month (~USD 11). On shared hosting common in Nepal at Rs 3,000–8,000/year (~USD 22–60), correct Apache or Nginx headers alone still reduce repeat bytes even without CDN control.

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: