Caching and Compression via .htaccess
ExpiresByType TTLs, immutable Cache-Control for hashed assets, which types deflate — and how to verify both with curl instead of guessing.
The two highest-leverage performance changes a .htaccess can make are compression (smaller bytes on the wire) and cache lifetime (no request at all on repeat visits). Both are a handful of directives — the details are where the mistakes live.
Compression: mod_deflate, but only for text
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE application/json
AddOutputFilterByType DEFLATE image/svg+xml
AddOutputFilterByType DEFLATE font/ttf
AddOutputFilterByType DEFLATE font/otf
</IfModule>
AddOutputFilterByType attaches gzip by MIME type — which forces you to think about which types, and that thinking is the whole game:
- Yes: HTML, CSS, JS, JSON, XML, SVG, plain text, TTF/OTF fonts. These compress 60–80%.
- No: JPEG, PNG, GIF, WebP, AVIF, WOFF/WOFF2, MP4, ZIP, PDF. Already compressed formats — gzip burns CPU and can return a larger file. Snippet collections that add
image/jpeg DEFLATEare cargo; the generator’s list deliberately excludes them. - Brotli: where the host loads
mod_brotli(some managed hosts do),AddOutputFilterByType BROTLI_COMPRESS text/html …squeezes ~15% further. Not stock Apache — check before relying on it.
Verify the negotiation, not the directive:
curl -sI -H "Accept-Encoding: gzip" https://example.com/style.css | grep -i content-encoding
# want: Content-Encoding: gzip
Cache lifetime: mod_expires
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresDefault "access plus 0 seconds"
</IfModule>
"access plus N units"— clock starts at the visitor’s first fetch, so a 1-month TTL means every client revalidates monthly, not on your deploy schedule.ExpiresDefaultis the floor.access plus 0 secondsmarks everything unlisted — critically, HTML — as immediately stale. Without it, HTML inherits nothing and browsers heuristically cache it, which is how “I deployed an hour ago and users still see the old page” happens.
How long is safe?
| Asset | Safe TTL | Why |
|---|---|---|
| HTML | 0 / short | Deploys must be visible at once |
| CSS/JS, unversioned filenames | 1 week–1 month | A stale copy breaks the whole page |
CSS/JS, fingerprinted (app.a1b2.js) |
1 year + immutable |
Name changes when content does — cache forever |
| Images, fonts, icons | 1 month–1 year | Rarely change; fingerprint if they do |
The fingerprint caveat is the one that matters: a 1-year TTL on a fixed filename is a promise you can’t keep — the next deploy changes the file but not the URL, and returning visitors keep the old one for months. Either version your asset names (bundlers do this automatically) or keep the TTL short.
The immutable upgrade for hashed assets
mod_headers adds what Expires can’t express — the immutable hint that stops browsers revalidating even on reload:
<IfModule mod_headers.c>
<FilesMatch "[.-][0-9a-f]{8,}\.(css|js|jpe?g|png|gif|webp|avif|svg|ico|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
The FilesMatch pattern targets bundler-style names (app.a1b2c3d4.js, style-0badf00d.css) — an 8+ hex run before the extension — so unversioned files aren’t swept into a year-long cache. Cache-Control beats Expires where both exist: browsers prefer it, so the header wins over any ExpiresByType overlap by design.
What these directives can’t reach
Static files served by Apache — all of the above works. Responses generated by PHP-FPM, a Node backend or a proxied app bypass ExpiresByType/AddOutputFilterByType in many setups (the response isn’t Apache’s file); those need headers from the app. Same for a CDN in front — edge caching has its own rules and will often strip or override origin headers. Test from the edge (curl -I against the public URL), not from localhost.
Verify, then walk away
curl -sI https://example.com/app.css | grep -iE 'expires|cache-control|content-encoding'
One command tells you all three: Expires/Cache-Control present with the intended max-age, Content-Encoding: gzip on text types. If the headers are absent and the <IfModule> guards are in the file, the module is missing or FileInfo overrides are off — that’s the 500-errors checklist territory.
Generate the block (and the immutable variant) from the generator — the compression list and TTL comments ship inside the output.