Peter Davies

Ramblings

Browser caching with mod_expires

Every page speed report flags "leverage browser caching" sooner or later. It means the server isn't telling the browser how long it can keep images, stylesheets and scripts, so the browser asks for them again on every page. On Apache the fix is a few lines of mod_expires. I've been pasting the same block into sites since the Magento days; this is that block brought up to date for Apache 2.4, with an explanation of each part.

Enable the modules

On Debian or Ubuntu:

a2enmod expires headers deflate
systemctl restart apache2

On RHEL and CentOS all three are loaded by default.

The mod_expires block

This can go in the virtual host or in the site's .htaccess (if AllowOverride permits it):

<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresDefault "access plus 1 month"

    ExpiresByType text/html "access"
    ExpiresByType text/xml "access plus 60 minutes"
    ExpiresByType application/xml "access plus 60 minutes"
    ExpiresByType application/json "access"

    ExpiresByType text/css "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    ExpiresByType text/javascript "access plus 1 year"

    ExpiresByType image/gif "access plus 1 month"
    ExpiresByType image/png "access plus 1 month"
    ExpiresByType image/jpeg "access plus 1 month"
    ExpiresByType image/svg+xml "access plus 1 month"
    ExpiresByType image/webp "access plus 1 month"
    ExpiresByType image/x-icon "access plus 1 year"

    ExpiresByType font/woff "access plus 1 year"
    ExpiresByType font/woff2 "access plus 1 year"
    ExpiresByType application/pdf "access plus 1 month"
    ExpiresByType video/mp4 "access plus 1 month"
</IfModule>

How it reads:

For each response, mod_expires sends both an Expires date and a Cache-Control: max-age= in seconds. Browsers use max-age when both are present.

Older Apache builds don't know the font types. If curl -I on a .woff2 file shows application/octet-stream, add the types first:

AddType font/woff .woff
AddType font/woff2 .woff2

Long expiry needs versioned file names

A year-long expiry on style.css means a returning visitor won't see your new stylesheet for a year. That's only safe if the file name or URL changes whenever the content does. WordPress adds ?ver= to enqueued scripts and styles, and Magento 2 puts a version number in the static path when static file signing is on. If your site does neither, keep CSS and JavaScript to a week or a month.

mod_headers for Cache-Control

The blocks I used to use added this as well:

<IfModule mod_headers.c>
    <FilesMatch "\.(ico|pdf|jpe?g|png|gif|svg|webp|js|css|woff2?)$">
        Header set Cache-Control "max-age=2629743, public"
    </FilesMatch>
</IfModule>

2629743 seconds is about a month. With the mod_expires block above in place this mostly duplicates it, and Header set overrides whatever mod_expires worked out, so having both means two places disagree. The useful part is public, which tells shared caches (a CDN or Varnish) they may store the file. If you want that, append it rather than replacing the whole header:

<IfModule mod_headers.c>
    <FilesMatch "\.(ico|pdf|jpe?g|png|gif|svg|webp|js|css|woff2?)$">
        Header append Cache-Control "public"
    </FilesMatch>
</IfModule>

ETags

Many optimisation snippets include:

FileETag None

On Apache 2.2 the default ETag included the file's inode, so the same file served from two servers behind a load balancer had two different ETags and browsers re-downloaded it. Apache 2.4 defaults to MTime Size, which is consistent across servers as long as deployments preserve timestamps. On a single server, leave ETags on; they let the browser revalidate a file with a cheap 304 once its expiry has passed. On several servers, either keep timestamps in sync or turn them off.

Compression, without the 2008 browser sniffing

The other block that always came with this was mod_deflate with a set of BrowserMatch lines for Netscape 4 and old Internet Explorer, plus Header append Vary User-Agent. Those browsers are gone, and Vary: User-Agent tells every cache in the path to store a separate copy for each browser version, which wrecks hit rates on a CDN or Varnish. All that's needed now is:

<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
    AddOutputFilterByType DEFLATE text/javascript application/javascript application/json
    AddOutputFilterByType DEFLATE application/xml image/svg+xml
</IfModule>

mod_deflate adds Vary: Accept-Encoding itself. Leave JPEG, PNG and other already-compressed formats out of the list; compressing them again costs CPU and saves nothing.

Checking it

curl -sI https://www.example.com/css/style.css | grep -iE 'cache-control|expires|etag|vary'
curl -sI -H 'Accept-Encoding: gzip' https://www.example.com/ | grep -i content-encoding

The first should show a max-age matching what you set for CSS. The second should show gzip. If nothing changes, check the module is loaded (apachectl -M | grep expires) and that .htaccess files are allowed to set it.