
September 12, 2026
12 min read
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.
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.
Example Cache-Control values by asset type
| Resource type | Recommended Cache-Control | Why |
|---|---|---|
Versioned JS/CSS (app.a1b2c3.js) | public, max-age=31536000, immutable | Filename changes on deploy; safe to cache forever |
| HTML pages | no-cache or short max-age=300 | Content updates frequently; revalidation preferred |
| Authenticated API | private, no-store | Prevents shared caches from leaking user data |
| Public API list endpoint | public, max-age=60, s-maxage=300 | CDN absorbs traffic; browser gets shorter TTL |
| User uploads / avatars | public, max-age=86400 | Changes 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.
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.
- Identify asset classes: static, semi-static, dynamic, sensitive.
- Set server-level rules for file extensions first.
- Add Laravel middleware only where responses are dynamic but cacheable.
- Configure CDN cache rules to respect origin Cache-Control.
- Purge CDN on deploy when HTML references non-fingerprinted assets.
- 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.
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-Controlis the primary HTTP caching header—set tiered policies for assets, HTML, and authenticated routes.- Use
public, max-age=31536000, immutableonly 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, andno-cachemean different things—mixing them up leaks data or kills performance.- Verify headers with
curl -Iat 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
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.

