CDN, HTTP Caching, Compression, and HTTP/2/3 Awareness
Learn how to offload static assets to a CDN, set correct HTTP caching headers, enable compression, understand how HTTP/2 and HTTP/3 change old performance advice, and apply asset optimization techniques like bundling, minification, and cache-busting filenames.
How fast a page feels to a user depends heavily on decisions made outside your PHP code entirely: how far the bytes have to travel, whether the browser already has them cached, how compressed they are, and how many separate connections were needed to fetch them. This lesson covers the delivery layer that sits between your PHP application and the browser — CDNs, HTTP caching headers, compression, and the practical implications of HTTP/2 and HTTP/3 for how you should (and shouldn't) structure your assets.
Using a CDN for static assets
A Content Delivery Network is a globally distributed set of edge servers that cache and serve your static assets (CSS, JavaScript, images, fonts) from a location physically close to each visitor, instead of every request traveling all the way to your single origin server. This cuts latency dramatically for geographically distant users and takes a huge amount of repetitive load off your PHP servers entirely, since a cached asset never even reaches PHP.
The typical setup points a CDN (Cloudflare, AWS CloudFront, Fastly) at your static asset URLs, either by serving assets from a dedicated subdomain/bucket the CDN pulls from, or by putting the CDN in front of your whole domain and letting it cache only the responses your HTTP headers mark as cacheable.
HTTP caching headers: Cache-Control and ETag
Cache-Control is the primary header controlling whether and how long a response can be cached, by both browsers and CDNs. Cache-Control: public, max-age=31536000, immutable tells everyone this response can be cached for a year and will never change at that URL — appropriate for a versioned, hashed asset filename. Cache-Control: no-store tells everyone never to cache this response at all — appropriate for sensitive, per-user data.
ETag provides a different mechanism: a hash or version identifier for a specific response body. When a browser has a cached copy, it sends If-None-Match: "<etag>" on the next request; if the server's current ETag matches, it replies with a lightweight 304 Not Modified and no body, telling the browser its cached copy is still valid, instead of re-sending the full response.
These two mechanisms are complementary: Cache-Control avoids a network round trip entirely for the max-age duration, while ETag (or Last-Modified) makes the round trip cheap when a full re-fetch is needed by allowing a 304 instead of re-downloading unchanged content.
Compression: gzip and brotli
Text-based responses (HTML, CSS, JavaScript, JSON) compress extremely well — often 70-80% smaller. Enabling compression means significantly fewer bytes travel over the network, at the cost of some CPU time to compress (and decompress, on the browser side, which is essentially free).
Brotli generally compresses better than gzip at the same speed and is supported by all modern browsers, so serving brotli when the client's Accept-Encoding header allows it (falling back to gzip otherwise) is the current best practice. This is usually configured at the web server (Nginx/Apache) or CDN level rather than in PHP code, since compressing at the edge is both faster and offloads the work from your application servers.
HTTP/2 and HTTP/3: what changed
HTTP/1.1 allowed only a handful of concurrent connections per domain (typically 6 in browsers), which led to performance folklore like domain sharding — splitting assets across multiple subdomains (static1.example.com, static2.example.com) purely to get around that connection limit and load more assets in parallel.
HTTP/2 introduced multiplexing: many requests and responses share a single TCP connection simultaneously, with no head-of-line blocking at the HTTP layer. This means domain sharding is now an anti-pattern — it forces extra DNS lookups and TCP/TLS handshakes for no benefit, and actively hurts performance under HTTP/2 and HTTP/3 compared to serving everything from fewer origins.
HTTP/3 goes further, replacing TCP with QUIC (built on UDP), which removes TCP-level head-of-line blocking entirely (a single lost packet no longer stalls every other in-flight stream) and speeds up connection setup, particularly valuable on unreliable mobile networks. Neither HTTP/2 nor HTTP/3 requires PHP application code changes — they're negotiated at the web server/CDN level — but they do mean you should undo old HTTP/1.1-era workarounds like domain sharding and, to a lesser extent, aggressive file concatenation done purely to reduce request count.
Asset optimization
Bundling combines many small CSS/JS files into fewer, larger files, and minification strips whitespace, comments, and shortens variable names — both still valuable under HTTP/2/3 for reducing total bytes transferred and parse time, even though the old 'fewer requests at any cost' motivation is weaker now.
Image optimization (choosing the right format — WebP/AVIF over JPEG/PNG where supported, compressing appropriately, and serving correctly sized images rather than a huge image scaled down by CSS) is often the single biggest page-weight win available, since images typically dominate total page size.
Versioned or content-hashed filenames (app.a1b2c3d4.js instead of app.js) let you set extremely aggressive far-future Cache-Control headers safely: since the filename itself changes whenever the content changes, there's no risk of serving a stale cached copy — the browser simply requests the new filename referenced in your updated HTML.
Setting Cache-Control and ETag headers from PHP
max-age=31536000 is one year, appropriate only for immutable, versioned/hashed filenames — never for a URL whose content can change without the filename changing. The 304 response has no body, saving the full transfer cost while still confirming freshness to the browser. In practice, static files like this are usually served directly by Nginx or a CDN rather than through PHP at all, precisely so PHP never has to run for a cache hit.
$etag = '"' . md5_file($filePath) . '"';
header('Cache-Control: public, max-age=31536000, immutable');
header('ETag: ' . $etag);
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? null;
if ($ifNoneMatch === $etag) {
http_response_code(304);
exit; // no body needed; browser's cached copy is confirmed valid
}
readfile($filePath);Code Examples
Different response types need very different caching policies. A hashed, versioned static asset can be cached for a full year because its filename guarantees content immutability. A personalized HTML page must never be cached by a shared cache (private, no-cache), while a public API response that changes slowly (like a leaderboard) can tolerate a short cache window plus stale-while-revalidate, which lets a CDN serve a slightly stale copy instantly while it refreshes in the background. Expected result: Requesting app.a1b2c3d4.css -> cached by the browser and any CDN for a year. Requesting the dashboard page -> Cache-Control: private, no-cache, must-revalidate, so it's always revalidated with the origin and never cached by a shared/CDN cache.
header('Cache-Control: private, no-cache, must-revalidate');
Common mistake: Setting Cache-Control: public, max-age=31536000 on a personalized HTML page would cause a CDN or shared proxy to serve one user's cached dashboard to a completely different user — a serious data leak, not just a caching bug.
Rather than hardcoding asset filenames in templates, the app looks them up through a manifest generated by the build step (e.g. Webpack, Vite, or esbuild), which maps a logical name to its content-hashed filename. When the CSS content changes, its hash — and therefore its filename — changes automatically, so the new HTML references the new file while old, unchanged assets keep their old (still-valid, still-cached) filenames. Expected result: Deploying a CSS change produces app.f4e5d6c7.css referenced in the HTML; browsers and CDNs that already cached the old app.a1b2c3d4.css forever are unaffected because it's simply never requested again. This pattern is what lets you safely combine 'cache this asset forever' with 'deploy updates instantly' — the two would otherwise be in direct conflict.
$hashedName = $this->manifestCache[$logicalName] ?? $logicalName;
<?php
declare(strict_types=1);
function sendCacheHeaders(string $contentType, bool $isVersionedAsset): void
{
if ($isVersionedAsset) {
// filename contains a content hash, e.g. app.a1b2c3d4.css -- safe to cache forever
header('Cache-Control: public, max-age=31536000, immutable');
} elseif (str_starts_with($contentType, 'text/html')) {
// dynamic, personalized page: don't let browsers or CDNs cache it
header('Cache-Control: private, no-cache, must-revalidate');
} else {
// an API response that's safe to cache briefly, e.g. a public leaderboard
header('Cache-Control: public, max-age=60, stale-while-revalidate=30');
}
header('Content-Type: ' . $contentType);
}
// A dynamic dashboard page: never cached
sendCacheHeaders('text/html; charset=utf-8', isVersionedAsset: false);
echo renderDashboard($currentUser);<?php
declare(strict_types=1);
final class AssetVersioner
{
/** @var array<string,string> */
private array $manifestCache = [];
public function __construct(private readonly string $manifestPath) {}
public function url(string $logicalName): string
{
if (empty($this->manifestCache)) {
$this->manifestCache = json_decode(
file_get_contents($this->manifestPath),
associative: true
);
}
$hashedName = $this->manifestCache[$logicalName] ?? $logicalName;
return "/assets/{$hashedName}";
}
}
// build step (run at deploy time) produces a manifest like:
// { "app.css": "app.a1b2c3d4.css", "app.js": "app.9f8e7d6c.js" }
$versioner = new AssetVersioner(__DIR__ . '/build/manifest.json');
?>
<link rel="stylesheet" href="<?= htmlspecialchars($versioner->url('app.css')) ?>">
<script src="<?= htmlspecialchars($versioner->url('app.js')) ?>" defer></script>HTTP/1.1 domain sharding vs. HTTP/2 multiplexing
Domain sharding solved an HTTP/1.1 connection-limit problem; under HTTP/2/3 multiplexing, it only adds DNS/TLS overhead and should be undone.
HTTP/1.1 (old advice: shard across domains to beat the ~6 connection limit)
static1.example.com --conn1,2,3--\
static2.example.com --conn4,5,6-- > Browser (many handshakes, many DNS lookups)
static3.example.com --conn7,8,9--/
HTTP/2 / HTTP/3 (multiplexed: one connection, many streams)
example.com ===[stream1|stream2|stream3|stream4|...]===> Browser
(single connection, single handshake, no sharding needed or beneficial)Important Rules
Serve static assets (CSS, JS, images, fonts) through a CDN so requests are answered from an edge location near the user and never reach your PHP servers at all.
Use Cache-Control: public, max-age=<long>, immutable only on content-hashed/versioned filenames; use private, no-cache for personalized HTML.
Pair long Cache-Control lifetimes with content-hashed filenames so deploys are instantly visible without needing to bust or wait out old caches.
Enable compression (prefer brotli, fall back to gzip) for all text-based responses — HTML, CSS, JS, JSON.
Do not shard static assets across multiple subdomains under HTTP/2 or HTTP/3 — multiplexing over a single connection is faster than the extra DNS/TLS overhead sharding introduces.
Optimize images (correct format, correct dimensions, appropriate compression) before optimizing almost anything else — images are usually the largest share of page weight.
Where this shows up
Every high-traffic site relies on this stack: a CDN (Cloudflare, CloudFront, Fastly) fronting static assets and often full pages, correct Cache-Control/ETag headers so browsers and CDNs don't re-fetch unchanged content, brotli/gzip compression on every text response, and build tooling (Vite, Webpack) that outputs content-hashed filenames automatically. On EngiLipi, course assets like syntax-highlighting CSS/JS, diagrams, and images are prime CDN and long-cache candidates, since they're identical for every visitor and change only on deploys.
header('Cache-Control: public, max-age=31536000'); // on /dashboard, a personalized page header('Cache-Control: private, no-cache, must-revalidate'); // on /dashboard header('Cache-Control: public, max-age=31536000, immutable'); // only on /assets/app.a1b2c3d4.css
Long, public caching on a personalized page can cause a shared cache or CDN to serve one user's private page content to another user entirely — long max-age is only safe on content that is identical for every visitor and never changes at that exact URL.
<link href="https://static1.example.com/a.css"> <link href="https://static2.example.com/b.css"> <script src="https://static3.example.com/c.js"></script>
<link href="https://static.example.com/a.css"> <link href="https://static.example.com/b.css"> <script src="https://static.example.com/c.js"></script>
Domain sharding was a workaround for HTTP/1.1's per-domain connection cap. Under HTTP/2/3 multiplexing, splitting assets across domains only adds extra DNS lookups and TLS handshakes with no parallelism benefit — serving everything from one origin is faster.
[Multiple Choice] Why is domain sharding now considered an anti-pattern? A. It was never actually effective, even under HTTP/1.1 B. HTTP/2 and HTTP/3 multiplex many requests over a single connection, so sharding only adds unnecessary DNS lookups and TLS handshakes C. CDNs no longer support multiple subdomains D. It makes compression impossible
B. HTTP/2 and HTTP/3 multiplex many requests over a single connection, so sharding only adds unnecessary DNS lookups and TLS handshakes — Domain sharding worked around HTTP/1.1's small per-domain connection limit. HTTP/2/3 multiplexing removes that limit by sending many requests over one connection, so sharding now costs extra connection setup for no benefit.
[Multiple Choice] What makes it safe to set Cache-Control: max-age=31536000, immutable on an asset? A. The asset is small in file size B. The filename includes a content hash, so any change to the content produces a new filename and the old cached copy is simply never requested again C. The server has a fast CPU D. The asset is served over HTTPS
B. The filename includes a content hash, so any change to the content produces a new filename and the old cached copy is simply never requested again — A content-hashed filename guarantees that URL's content never changes; if the content is updated, the hash (and filename) changes too, so long caching on the old, unchanged filename causes no staleness problems.
[True / False] An ETag lets a server respond with a lightweight 304 Not Modified instead of re-sending the full response body when the client's cached copy is still valid. A. True B. False
A. True — True. The browser sends its stored ETag back via If-None-Match; if it matches the server's current ETag, the server replies 304 with no body, confirming the cached copy is still good.
A site serves /style.css with no cache headers at all (so browsers re-validate on every load) and shards its three JS bundles across static1/static2/static3 subdomains 'for parallelism,' as the team read in an old blog post. List the two changes you'd make and explain the reasoning for each in one sentence.
1) Rename the file to include a content hash (e.g. style.a1b2c3d4.css) and set Cache-Control: public, max-age=31536000, immutable, so it's cached for a year and busted automatically whenever its content and hash change. 2) Consolidate the JS bundles onto a single origin, since HTTP/2/3 multiplexes many requests over one connection — sharding now only adds extra DNS/TLS handshake overhead with no parallelism benefit.
Think about what content-hashed filenames enable for the first issue, and what HTTP/2/3 changed about connection limits for the second.