Header set vs Header always set: why your 304s ship without Cache-Control

You add long cache lifetimes to your static assets. You check with curl. The header is there. You re-measure in Chrome and it still reports the files as uncacheable.

The cause is one word.

# does not do what you think
Header set Cache-Control "public, max-age=31536000, immutable"

# correct
Header always set Cache-Control "public, max-age=31536000, immutable"

Why the difference matters

Apache keeps response headers in several tables. Plain Header set writes only to onsuccess, which covers 2xx responses. Header always set writes to the always table, which covers everything else too, including redirects, errors and 304 Not Modified.

That last one is the problem. If your assets carry an ETag, and Apache adds one to static files by default, then a returning browser sends a conditional request and receives a 304. With plain Header set, that 304 arrives with no Cache-Control at all.

The browser therefore never learns the file is cacheable, so it revalidates again on the next page load, and the next, forever.

Testing it properly

Testing the 200 tells you nothing. You have to test the 304:

URL=https://example.com/wp-content/themes/x/fonts/font.woff2

# grab the ETag
ETAG=$(curl -sSI "$URL" | grep -i '^etag' | sed 's/[Ee][Tt]ag: //' | tr -d '\r')

# ask for it conditionally
curl -sSI -H "If-None-Match: $ETAG" "$URL" | grep -iE '^(HTTP|cache-control)'

You want to see both lines:

HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=31536000, immutable

If the Cache-Control line is missing from the 304, you have the bug.

A working block

Useful when mod_expires is not available, which is the case on stock Bitnami stacks where only mod_headers and mod_rewrite are loaded:

<IfModule mod_headers.c>
    <FilesMatch "\.(?i:woff2?|ttf|otf|eot|jpe?g|png|gif|webp|avif|svg|ico|css|js|mp4|webm)$">
        Header always set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>
</IfModule>

Leave HTML out of it. WordPress and caching plugins set their own Cache-Control on HTML, and a blanket rule here will also override the no-cache headers protecting logged-in and admin pages.

Before you ship a one-year immutable cache

Make sure your assets are actually version-busted first, or a deploy will leave returning visitors on stale CSS for a year.

In WordPress, an wp_enqueue_style() call with no $ver argument falls back to the WordPress core version. That only changes when core updates, not when you rebuild your stylesheet. Use the file modification time instead:

function theme_asset_version( $relative_path ) {
    $file = get_stylesheet_directory() . $relative_path;
    return file_exists( $file ) ? (string) filemtime( $file ) : false;
}

wp_enqueue_style( 'main', get_stylesheet_directory_uri() . '/dist/main.css',
    array(), theme_asset_version( '/dist/main.css' ) );

Now a deploy changes the URL, the cache busts by itself, and a one-year lifetime is safe.

Illustration of a LibraFire team member on a video call with a client

How can we assist you?

Contact Us