A server with a 6ms TTFB didn't need a cache layer. I just hadn't known that FilesMatch doesn't apply to proxied responses.
I run about ten sites on my personal server. All of them run on Node, with Apache in front as a reverse proxy. One day it occurred to me, "can't I make this a bit faster?" The first thing that came to mind was Redis. Adding a cache layer felt like it would make something faster. I briefly thought about a load balancer too. But before adding anything, I decided to measure first. To give the conclusion up front, I didn't add Redis. There was nowhere to put it. The real cause was somewhere else entirely, and fixing it took a single Apache config file. This post walks through that process in the order I measured things. Measure first I started by checking whether the server was actually slow. $ curl -o /dev/null -w "%{time_starttransfer}\n" https://mydomain/ 0.006 TTFB 6ms. I measured all ten sites and they were all between 3ms and 6ms. If that's how long the server takes to start responding, there's nothing left to cut here. I looked at the DB too. Uptime 201 hours, 146,608 total queries (0.2 per second) Slow queries 0 InnoDB buffer pool hit rate 99.98% 0.2 queries per second. Not a single slow query, and with a buffer pool hit rate of 99.98% it was barely reading the disk and handling everything from memory. The load average was 0.18. Redis is something to use "when the DB is slow and results need to be held in memory" . But MySQL was already handling everything from memory, and there was no load to begin with. Attaching Redis here would only add one more process, one more thing to manage, and one more chance of a cache invalidation bug. The speed anyone actually feels would improve by 0ms. The load balancer was the same story. A load balancer splits traffic when there are several backend servers, and the server at my house is a single machine. With nothing to distribute across, it just adds a hop and creates a new single point of failure. And if that one machine dies, the load balancer has nowhere to send anything. Then what is slow? If it's not the server, it's the browser side. So I looked at what headers one of the built JS files was being served with. $ curl -I https://mydomain/assets/react-dom-0e8399ab.js cache-control: public, max-age=0, max-age=2592000 etag: W/"1fb72-1a084ad10cc" That's odd. max-age is in there twice. And the 0 comes first. When browsers hit a duplicate directive, they usually go with the first value. In other words, these were build outputs with a hash in the filename, and the browser was still revalidating them every time. Thanks to the ETag it ends with a 304, but the round trip itself still happens. I checked the other sites as well, and all ten had the same symptom. The favicon looked like this. cache-control: public, max-age=0, max-age=31536000 The strangest one was a single site. Its Node app was sending proper cache headers, and it still came out like this. cache-control: public, max-age=31536000, immutable, max-age=2592000 Something was tacking 30 days on after the correct value the app had sent. That means fixing the app wouldn't help. The culprit was Apache. The config was written correctly, though I opened the Apache config. The cache settings were already in good shape. IfModule mod_headers.c FilesMatch "\.(css|js)
quot; Header set Cache-Control "public, max-age=2592000, immutable" /FilesMatch /IfModule As it shows, I'd even added immutable . But there was no immutable anywhere in the actual responses. That was the hint. If Header set had run, immutable would have to be there. Its absence meant this block was never executed at all . FilesMatch doesn't apply to proxied responses The reason was in the vhost config. ProxyPass / http://localhost:5015/ ProxyPassReverse / http://localhost:5015/ Every site is proxied to Node like this. Apache doesn't serve a single file itself. FilesMatch is, as the name says, a directive that matches "files". To Apache, a proxied response is just a byte stream sent by some other server, not a local file. With no file to match, the whole block is dead. On my server all ten sites are proxied, so that mod_headers block had never worked, not even once. Then who added the 30 days? Further up in the config there was this as well. IfModule mod_expires.c ExpiresActive On ExpiresByType application/javascript "access plus 1 month" /IfModule 1 month = 2592000 seconds. The number from earlier. mod_expires works off the Content-Type. It looks at the response header, not at a file, so it applies to proxied responses just fine. The problem is how it works. mod_expires doesn't replace the existing Cache-Control , it takes max-age and appends it. So this is what had been happening. Express (express.static default) -> public, max-age=0 mod_expires appends -> public, max-age=0, max-age=2592000 mod_headers never runs -> (no immutable) To sum up, the setting I intended wasn't applied, and only the one I didn't intend was . Both were silent, so I didn't know for months. Switching to expr= mod_expires came out, and I decided to use only mod_headers with its expr= condition. expr= looks at the URL or the Content-Type, so it works on both proxied responses and local files. And Header set replaces the value the backend sent instead of appending to it. IfModule mod_headers.c # 1) Hashed-filename assets - cache forever Header set Cache-Control "public, max-age=31536000, immutable" \ "expr=%{REQUEST_URI} =~ m#^/assets/.+\.(js|mjs|css|woff2?|png|svg|ico)$#" # 2) Service worker - no caching Header set Cache-Control "no-cache, must-revalidate" \ "expr=%{REQUEST_URI} =~ m#^/(sw\.js|registerSW\.js|[^/]*\.webmanifest)$#" # 3) Unhashed images at the root - one day Header set Cache-Control "public, max-age=86400" \ "expr=%{REQUEST_URI} =~ m#^/[^/]*\.(png|jpe?g|svg|ico)$# ! %{resp:Cache-Control} =~ m#max-age=[1-9]#" # 4) HTML - always revalidate Header set Cache-Control "no-cache, must-revalidate" \ "expr=%{CONTENT_TYPE} =~ m#^text/html# ! %{resp:Cache-Control} =~ m#max-age=[1-9]#" /IfModule A few things tripped me up while writing these rules. A service worker must not get one year At first I was going to give every .js file a one-year immutable . Then I opened the dist folder and stopped. dist/ ├── assets/ - all hashed filenames (index-ae0ed0e9.js) ├── sw.js - no hash └── registerSW.js - no hash Two of the sites were built as PWAs and had service workers. Their filenames are fixed, so the URL stays the same. Put a one-year immutable on that and the browser keeps using the old service worker forever, even after a new deploy. I nearly blocked my own deploys while trying to turn caching on. So I limited the caching rule to the /assets/ path. Vite drops all its build output there under hashed names, so the path alone is enough to pick out exactly the "files whose name changes when the content changes". For the service worker I set no-cache explicitly in a separate rule. HTML can't be caught by path The original config also had this. FilesMatch "\.(html|json)
quot; This rule wouldn't work even with the proxy problem set aside. An SPA's entry paths, like / or /dashboard , have no extension. \.html$ will never match them. So for HTML alone, I made the check go by Content-Type instead of by path. That way routes with no extension are all covered too. Keeping HTML at no-cache matters. If index.html gets cached, the browser can't read the asset hashes of the new deploy and users get stuck on the old screen. Caching assets for a year while checking index.html every time is the whole point of this combination. Leaving the app's own cache settings alone I stepped on one thing while verifying after the deploy. The terms page on the crypto site looked like this. // app.js app.get('/privacy', (req, res) = { res.setHeader('Cache-Control', 'public, max-age=3600'); ... }); It's a static terms page, so the app deliberately caches it for 1 hour, and my HTML rule overwrote that with no-cache . It was ignoring the app's decision. So I added one condition to rules 3 and 4. ! %{resp:Cache-Control} =~ m#max-age=[1-9]# It means "if the app has already set a valid max-age, don't touch it". The default that express.static puts on index.html is max-age=0 , which doesn't match [1-9] , so the SPA's index.html still gets replaced as intended. API responses (JSON) I left out of the rules entirely. Results I measured the ten sites again, on a repeat visit. Requests Transferred Before -> 175 1572KB After -> 11 50KB (94% less) (97% less) The remaining 11 are the 10 index.html files, one per site, and 1 service worker. Those are supposed to be checked every time, so in the end asset requests went down to 0 . The headers got cleaned up like this too. /assets/*.js -> public, max-age=31536000, immutable / -> no-cache, must-revalidate /sw.js -> no-cache, must-revalidate /privacy -> public, max-age=3600 (the app's own value, untouched) What I'm left with What if I had added Redis? It probably would have run fine, and I would have thought I'd improved something. And the browser would still have been rechecking nineteen assets on every visit. I'm glad I measured. Putting a cache layer on a server with a TTFB of 3ms to 6ms and 0 slow queries would only have added one more thing to manage. And "I wrote the config" and "the config works" are different things. FilesMatch sat quietly in the file for months, with no syntax errors, and it passed apache2ctl configtest too. It just never ran. A config that fails silently doesn't leave a log either. For anyone running Node behind nginx or Apache, it's worth checking once. One command is all it takes. curl -I https://mysite/assets/anything.js | grep -i cache-control max-age showing up twice means caching isn't working. For reference, this is the output of the same command run again on the day I revised this post. Assets are immutable for one year, HTML and the service worker are no-cache, and the terms page the app sets itself is still at 1 hour.