If it doesn't load in less than three frames, I don't want it.
The last person to care about a blog post telling a tale of a static site generator migration probably stopped doing so during the COVID lockdown, so I'll dive into a different topic that has at least been unfashionable long enough for it to be sufficiently retro1: page speed optimization. In the golden age of slop, I feel this bit sometimes gets left behind, so join me in pretending it is 2011 again:
Let's take this freshly relaunched page and drop it into a random website speed benchmark. The 2.9 s draw speed is concerning, but at least we are getting a ninety-out-of-ten score, which is a decent point to start from. Much more fun, though, is looking at real page loads in Firefox on our machine:
Figure 1: The initial Firefox HTTP/1.1 capture. Background stripes are 16.67 ms (one frame at 60 fps) each.
Figure 1 shows the request sequence and load times in a waterfall diagram, data courtesy of Firefox's web development tools. A connaisseur of these charts will also immediately point out we are cheating: some TLS handshakes are not visible because this was on a "warm" connection; we are hand-waving all that away, along with all browser painting.
The sin of fonts
The chart is catastrophic: DOMContentLoaded fires at 32 ms, but it takes over 200 ms to arrive at load. Separate stylesheets and pretty fonts2 are huge problems, with the fonts alone adding about 230 kB.
The floor for a single round trip, measured by pinging the website host, is roughly 16 ms3. Transferring 230 kB through my 150 Mbit/s residential DSL takes 12 ms at a minimum, only a third of the delay introduced by the two additional dependency stages. Multiple connections are used, some newly created, with TCP and TLS setup easily eclipsing the transfer time.
The solution is to reduce dependency round trips, request count, and size by picking some low-hanging fruit:
Reducing the fonts' glyph sets to those actually used brings the fonts down from about 220 kB to 80 kB.
Adding <link rel=preload> tags allows the browser to start fetching the CSS and fonts at the same time, instead of only discovering them in the CSS.
Applying CSS bundling ensures we have a single stylesheet to fetch.
Enabling compression on the server for CSS files (HTML was already gzip-compressed on the fly) is a cheap gain, albeit WOFF2 files are already Brötli-compressed.
We are down to five requests and two round trips, and our load time went down a full 69%4.
Aside: Privacy, people!
At this point, someone might point out that I could have loaded the fonts from a CDN/Google/Cloudflare/the local NSA proxy at my ISP; maybe they'd be in the user's cache already, and so on. My answer to that is a resounding no. Even though we live in an age where companies load megabytes of JavaScript to track every mouse movement for "session replay", I still don't think it defensible to toss out user privacy to save 100 kB of traffic during their entire visit.
One step further
What is better than three requests for fonts? One request for fonts, of course. Sadly, that avenue is not open to us, since for at least seven years, Firefox has not supported font collections in WOFF2.
But what if we killed four requests with one hack and just abused url(data:...) in CSS to Base64-encode all of the font files, then merged everything into one giant CSS file? Yes, it'll bloat the fonts by encoding, but we're counting on gzip compression bailing us out. Let's try it:
Figure 3: The capture after embedding the four font subsets.
Eww. The payload grew by less than 4%5, but why are we suddenly taking ages to transfer a measly 120 kB? The answer is TCP congestion control. In short, a server can send only a certain amount of data upfront before it needs acknowledgements. If the response exceeds this initial window, it has to wait for ACKs, adding a round-trip delay on that connection.
Luckily, we can increase the initial congestion window to IW100: an initial window of 100 TCP segments. We did not measure the negotiated maximum segment size, but assuming a conservative 1,400-byte MSS, IW100 permits roughly 140 kB of TCP payload initially:
root@www:~# ip route change default \
via 172.31.1.1 \
dev eth0 \
proto boot \
initcwnd 100
root@www:~# ip route get 9.9.9.9
9.9.9.9 via 172.31.1.1 dev eth0 src 157.90.165.43 uid 0
cache initcwnd 100
That is enough for the roughly 126.3 kB of combined HTTP responses and TLS overhead. I should point out that going beyond IW10 is something you should only do if you know what you are doing6, as it can negatively impact connections under less-than-ideal conditions.
Figure 4: Timings after raising the initial congestion window.
We are back down to reasonable numbers, albeit still slower than before our optimization at 92 ms versus our previous best of 75 ms. Disregarding our data7 for a moment, we're down to an impressive two requests, which will come in handy for what we are about to do.
One step too far
Since cramming everything into one file worked so well, why not take it further than that? After all, why shouldn't we just inline the stylesheet into every page, so that the entire thing arrives with the first page load? That gets us the absolute minimum of one HTTP request for a cache-cold load. The capture below is connection-warm but cache-cold:
Figure 5: The inlined page, uncached but over a reused connection.
Now we're cookin'! The VPS is a pretty old instance, so let's check the available tools before blaming compression overhead.
A seasoned veteran. This Nginx has no Zstd or Brötli module, but it can serve precompressed gzip. Since every page now embeds the fonts, we can run gzip -9 during the build, put a .gz file next to each page, and configure Nginx to serve it:
Figure 6: The precompressed page over a reused connection.
Precompression changes both the response size and Nginx's output path, so this capture does not isolate compression overhead. Serving the fonts each time should still leave a bad taste in our mouth, but we cannot argue with a single request and 36 ms total load time, which is barely over two frames. Even the random benchmark site has to agree here, giving us 100/1008.
...and beyond
Taking home the lesson that latency is expensive and bandwidth is cheap, provided you're serving only 3 kB worth of content, feels hollow because we have to live with the shame of serving 119 kB more than necessary on subsequent page loads. How about we fix that by serving the content twice?9
Aspiring to make the setup cursed enough to still haunt our grandchildren, we can do the following:
Serve the all-in-one 122 kB site in a single response that fits inside IW100, setting a have-precached-assets cookie.
In that page, instruct the browser to fetch all of the CSS and font files again, except in a non-bundled form, through <link rel=preload> tags.
Next time the user requests a page with the have-precached-assets cookie, serve a non-bundled version of the page.
I replaced the eight-year-old nginx with Caddy and implemented this scheme, the result looks like this on index load:
Figure 7: The inlined page while Firefox preloads standalone copies of the CSS and fonts.
The console spews a few angry messages like
The resource at “http://127.0.0.1:1111/css/site-standalone.css” preloaded with link preload was not used within a few seconds. Make sure all attributes of the preload tag are set correctly.
since we technically should have used prefetch instead of preload, but that would make uBlock Origin users miss out, since it blocks prefetches, and they deserve better10. Now navigating to an article transfers 5.4 kB total and looks like this:
Figure 8: The standalone page with its stylesheet and fonts served from cache.
What about HTTP>1?
This sort of exhausts the limits of serving static sites over HTTP/1.111. HTTP/2 mostly solves multiplexing issues, but for our single-request case, it does very little, especially with HTTP/2 server push being deprecated. HTTP/3 over QUIC can save a round trip when establishing a new connection by combining its transport and TLS handshakes. Browsers can learn about it from Alt-Svc on the first response; making it available before that requires making DNS an accomplice with an HTTPS resource record.
On the other hand, the next article now loads in under three frames. I should probably go write it.
At this point I wonder if this website needs a WAP version, if only to reclaim the acronym. ↩
The previous design might have been ugly, but it used zero custom fonts, at least. ↩
The actual physical floor would be based on the distance from my home to the datacenter, which is about 200 km away. At the speed of light, a round trip would be 1.2 ms, or 2 ms in optical fiber. ↩
This footnote does not say what you expected, does it? I'm better than that. ↩
I smuggled the previously dynamically loaded italic font into the old total. ↩
I am told, at least. Unless you are writing a blog post and need to pump those numbers up, then I of course fully endorse it! ↩
A tried-and-true strategy: if the benchmark does not support your narrative, just drop it, aka "noise". ↩