Compiler says no!

Everything in one request

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:

Initial page load Waterfall chart of 7 network requests over 250 milliseconds. Firefox 152.0.3 | HTTP/1.1 | 7 network requests | 263.3 KiB reported transfer REQUEST TRANSFER 0 ms 0 ms 50 ms 50 ms 100 ms 100 ms 150 ms 150 ms 200 ms 200 ms 250 ms 250 ms GET https://compilersaysno.com/ | status 200, reported transfer 3.6 KiB, start 0.0 ms, total 16.0 ms | wait 16.0 ms / 3.6 KiB GET inline SVG | status 200, reported transfer 0 B, start 34.0 ms, total 0.0 ms inline SVG 0 B GET https://compilersaysno.com/syntax-dark.css | status 200, reported transfer 3.2 KiB, start 71.0 ms, total 16.0 ms | wait 16.0 ms syntax-dark.css 3.2 KiB GET https://compilersaysno.com/css/site.css | status 200, reported transfer 15.2 KiB, start 72.0 ms, total 47.0 ms | tcp 16.0 ms | wait 31.0 ms site.css 15.2 KiB GET https://compilersaysno.com/syntax-light.css | status 200, reported transfer 14.8 KiB, start 123.0 ms, total 16.0 ms | wait 16.0 ms syntax-light.css 14.8 KiB GET https://compilersaysno.com/fonts/source-serif-4/SourceSerif4-Regular.ttf.woff2 | status 200, reported transfer 74.7 KiB, start 135.0 ms, total 54.0 ms | wait 36.0 ms | receive 18.0 ms SourceSerif4-Regu... 74.7 KiB GET https://compilersaysno.com/fonts/source-serif-4/SourceSerif4-Semibold.ttf.woff2 | status 200, reported transfer 79.1 KiB, start 135.0 ms, total 52.0 ms | tcp 19.0 ms | wait 16.0 ms | receive 17.0 ms SourceSerif4-Semi... 79.1 KiB GET https://compilersaysno.com/fonts/source-code-pro/SourceCodePro-Regular.ttf.woff2 | status 200, reported transfer 72.6 KiB, start 136.0 ms, total 100.0 ms | tcp 16.0 ms | tls 35.0 ms | wait 32.0 ms | receive 17.0 ms SourceCodePro-Reg... 72.6 KiB DOMContentLoaded 32 ms DOMContentLoaded 32 ms load 239 ms load 239 ms Blocked DNS TCP TLS Send Wait Receive DOMContentLoaded load
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.
Subsets and bundled styles Waterfall chart of 5 network requests over 250 milliseconds. Firefox 152.0.3 | HTTP/1.1 | 5 network requests | 95.1 KiB reported transfer REQUEST TRANSFER 0 ms 0 ms 50 ms 50 ms 100 ms 100 ms 150 ms 150 ms 200 ms 200 ms 250 ms 250 ms GET https://compilersaysno.com/ | status 200, reported transfer 3.7 KiB, start 0.0 ms, total 17.0 ms | wait 17.0 ms / 3.7 KiB GET inline SVG | status 200, reported transfer 0 B, start 27.0 ms, total 0.0 ms inline SVG 0 B GET https://compilersaysno.com/fonts/2367286-source-serif-4-regular.woff2 | status 200, reported transfer 30.4 KiB, start 37.0 ms, total 32.0 ms | wait 31.0 ms | receive 1.0 ms 2367286-source-se... 30.4 KiB GET https://compilersaysno.com/fonts/41b7257-source-serif-4-semibold.woff2 | status 200, reported transfer 32.2 KiB, start 39.0 ms, total 32.0 ms | wait 31.0 ms | receive 1.0 ms 41b7257-source-se... 32.2 KiB GET https://compilersaysno.com/fonts/b81a45a-source-code-pro-regular.woff2 | status 200, reported transfer 22.2 KiB, start 39.0 ms, total 31.0 ms | wait 31.0 ms b81a45a-source-co... 22.2 KiB GET https://compilersaysno.com/css/site.css | status 200, reported transfer 6.5 KiB, start 39.0 ms, total 17.0 ms | wait 17.0 ms site.css 6.5 KiB DOMContentLoaded 25 ms DOMContentLoaded 25 ms load 75 ms load 75 ms Blocked DNS TCP TLS Send Wait Receive DOMContentLoaded load
Figure 2: Capture after reducing glyphs, bundling CSS, preloading, enabling compression.

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:

Embedded fonts: two requests Waterfall chart of 2 network requests over 250 milliseconds. Firefox 152.0.3 | HTTP/1.1 | 2 network requests | 123.0 KiB reported transfer REQUEST TRANSFER 0 ms 0 ms 50 ms 50 ms 100 ms 100 ms 150 ms 150 ms 200 ms 200 ms 250 ms 250 ms GET https://compilersaysno.com/ | status 200, reported transfer 3.6 KiB, start 0.0 ms, total 16.0 ms | wait 16.0 ms / 3.6 KiB GET inline SVG | status 200, reported transfer 0 B, start 26.0 ms, total 0.0 ms inline SVG 0 B GET https://compilersaysno.com/css/site.css | status 200, reported transfer 119.4 KiB, start 33.0 ms, total 71.0 ms | wait 39.0 ms | receive 32.0 ms site.css 119.4 KiB GET inline WOFF2 #1 | status 200, reported transfer 0 B, start 107.0 ms, total 0.0 ms inline WOFF2 #1 0 B GET inline WOFF2 #2 | status 200, reported transfer 0 B, start 107.0 ms, total 0.0 ms inline WOFF2 #2 0 B GET inline WOFF2 #3 | status 200, reported transfer 0 B, start 109.0 ms, total 0.0 ms inline WOFF2 #3 0 B DOMContentLoaded 25 ms DOMContentLoaded 25 ms load 114 ms load 114 ms Blocked DNS TCP TLS Send Wait Receive DOMContentLoaded load
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.

Embedded fonts with IW100 Waterfall chart of 2 network requests over 250 milliseconds. Firefox 152.0.3 | HTTP/1.1 | 2 network requests | 123.0 KiB reported transfer REQUEST TRANSFER 0 ms 0 ms 50 ms 50 ms 100 ms 100 ms 150 ms 150 ms 200 ms 200 ms 250 ms 250 ms GET https://compilersaysno.com/ | status 200, reported transfer 3.6 KiB, start 0.0 ms, total 16.0 ms | wait 16.0 ms / 3.6 KiB GET inline SVG | status 200, reported transfer 0 B, start 27.0 ms, total 0.0 ms inline SVG 0 B GET https://compilersaysno.com/css/site.css | status 200, reported transfer 119.4 KiB, start 47.0 ms, total 33.0 ms | wait 22.0 ms | receive 11.0 ms site.css 119.4 KiB GET inline WOFF2 #1 | status 200, reported transfer 0 B, start 84.0 ms, total 0.0 ms inline WOFF2 #1 0 B GET inline WOFF2 #2 | status 200, reported transfer 0 B, start 85.0 ms, total 0.0 ms inline WOFF2 #2 0 B GET inline WOFF2 #3 | status 200, reported transfer 0 B, start 86.0 ms, total 0.0 ms inline WOFF2 #3 0 B DOMContentLoaded 25 ms DOMContentLoaded 25 ms load 92 ms load 92 ms Blocked DNS TCP TLS Send Wait Receive DOMContentLoaded load
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:

Inlined CSS: warm connection Waterfall chart of 1 network requests over 250 milliseconds. Firefox 152.0.3 | HTTP/1.1 | 1 network requests | 123.1 KiB reported transfer REQUEST TRANSFER 0 ms 0 ms 50 ms 50 ms 100 ms 100 ms 150 ms 150 ms 200 ms 200 ms 250 ms 250 ms GET https://compilersaysno.com/ | status 200, reported transfer 123.1 KiB, start 0.0 ms, total 36.0 ms | wait 23.0 ms | receive 13.0 ms / 123.1 KiB GET inline SVG | status 200, reported transfer 0 B, start 46.0 ms, total 0.0 ms inline SVG 0 B GET inline WOFF2 #1 | status 200, reported transfer 0 B, start 49.0 ms, total 0.0 ms inline WOFF2 #1 0 B GET inline WOFF2 #2 | status 200, reported transfer 0 B, start 49.0 ms, total 0.0 ms inline WOFF2 #2 0 B GET inline WOFF2 #3 | status 200, reported transfer 0 B, start 51.0 ms, total 0.0 ms inline WOFF2 #3 0 B DOMContentLoaded 45 ms DOMContentLoaded 45 ms load 59 ms load 59 ms Blocked DNS TCP TLS Send Wait Receive DOMContentLoaded load
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.

root@www:~# uptime
 14:35:09 up 1984 days,  1:57,  1 user,  load average: 0.00, 0.00, 0.00
root@www:~# nginx -v
nginx version: nginx/1.14.2

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:

Static gzip: warm connection Waterfall chart of 1 network requests over 250 milliseconds. Firefox 152.0.3 | HTTP/1.1 | 1 network requests | 118.6 KiB reported transfer REQUEST TRANSFER 0 ms 0 ms 50 ms 50 ms 100 ms 100 ms 150 ms 150 ms 200 ms 200 ms 250 ms 250 ms GET https://compilersaysno.com/ | status 200, reported transfer 118.6 KiB, start 0.0 ms, total 19.0 ms | wait 16.0 ms | receive 3.0 ms / 118.6 KiB GET inline SVG | status 200, reported transfer 0 B, start 29.0 ms, total 0.0 ms inline SVG 0 B GET inline WOFF2 #1 | status 200, reported transfer 0 B, start 29.0 ms, total 0.0 ms inline WOFF2 #1 0 B GET inline WOFF2 #2 | status 200, reported transfer 0 B, start 30.0 ms, total 0.0 ms inline WOFF2 #2 0 B GET inline WOFF2 #3 | status 200, reported transfer 0 B, start 31.0 ms, total 0.0 ms inline WOFF2 #3 0 B DOMContentLoaded 26 ms DOMContentLoaded 26 ms load 36 ms load 36 ms Blocked DNS TCP TLS Send Wait Receive DOMContentLoaded load
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:

  1. Serve the all-in-one 122 kB site in a single response that fits inside IW100, setting a have-precached-assets cookie.
  2. 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.
  3. 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:

Standalone asset preloads Waterfall chart of 6 network requests over 250 milliseconds. Firefox 152.0.3 | HTTP/1.1 | 6 network requests | 259.1 KiB reported transfer REQUEST TRANSFER 0 ms 0 ms 50 ms 50 ms 100 ms 100 ms 150 ms 150 ms 200 ms 200 ms 250 ms 250 ms GET https://compilersaysno.com/ | status 200, reported transfer 118.7 KiB, start 0.0 ms, total 23.0 ms | wait 19.0 ms | receive 4.0 ms / 118.7 KiB GET inline SVG | status 200, reported transfer 0 B, start 33.0 ms, total 0.0 ms inline SVG 0 B GET inline WOFF2 #1 | status 200, reported transfer 0 B, start 34.0 ms, total 0.0 ms inline WOFF2 #1 0 B GET inline WOFF2 #2 | status 200, reported transfer 0 B, start 35.0 ms, total 0.0 ms inline WOFF2 #2 0 B GET inline WOFF2 #3 | status 200, reported transfer 0 B, start 36.0 ms, total 0.0 ms inline WOFF2 #3 0 B GET https://compilersaysno.com/css/site-standalone.css | status 200, reported transfer 31.6 KiB, start 80.0 ms, total 20.0 ms | wait 19.0 ms | receive 1.0 ms site-standalone.css 31.6 KiB GET https://compilersaysno.com/fonts/2367286-source-serif-4-regular.woff2 | status 200, reported transfer 30.3 KiB, start 81.0 ms, total 25.0 ms | wait 24.0 ms | receive 1.0 ms 2367286-source-se... 30.3 KiB GET https://compilersaysno.com/fonts/f4412d4-source-serif-4-italic.woff2 | status 200, reported transfer 24.2 KiB, start 81.0 ms, total 26.0 ms | wait 25.0 ms | receive 1.0 ms f4412d4-source-se... 24.2 KiB GET https://compilersaysno.com/fonts/41b7257-source-serif-4-semibold.woff2 | status 200, reported transfer 32.1 KiB, start 81.0 ms, total 23.0 ms | wait 22.0 ms | receive 1.0 ms 41b7257-source-se... 32.1 KiB GET https://compilersaysno.com/fonts/b81a45a-source-code-pro-regular.woff2 | status 200, reported transfer 22.2 KiB, start 82.0 ms, total 21.0 ms | wait 20.0 ms | receive 1.0 ms b81a45a-source-co... 22.2 KiB DOMContentLoaded 32 ms DOMContentLoaded 32 ms load 41 ms load 41 ms Blocked DNS TCP TLS Send Wait Receive DOMContentLoaded 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:

Standalone page with cached assets Waterfall chart of 1 network request and 5 cache hits over 250 milliseconds. Firefox 152.0.3 | HTTP/1.1 | 1 request | 5 cache hits | 5.4 KiB transfer REQUEST TRANSFER 0 ms 0 ms 50 ms 50 ms 100 ms 100 ms 150 ms 150 ms 200 ms 200 ms 250 ms 250 ms GET https://compilersaysno.com/posts/fault-tolerant-stolen-wifi/ | status 200, reported transfer 5.4 KiB, start 0.0 ms, total 20.0 ms | wait 20.0 ms fault-tolerant-st... 5.4 KiB GET https://compilersaysno.com/css/site-4d5b6cb0b013.css | status 200, cache hit, start 29.0 ms, total 0.0 ms site-4d5b6cb0b013... cache GET inline SVG | status 200, reported transfer 0 B, start 34.0 ms, total 0.0 ms inline SVG 0 B GET https://compilersaysno.com/fonts/2367286-source-serif-4-regular.woff2 | status 200, cache hit, start 70.0 ms, total 0.0 ms 2367286-source-se... cache GET https://compilersaysno.com/fonts/f4412d4-source-serif-4-italic.woff2 | status 200, cache hit, start 73.0 ms, total 0.0 ms f4412d4-source-se... cache GET https://compilersaysno.com/fonts/41b7257-source-serif-4-semibold.woff2 | status 200, cache hit, start 74.0 ms, total 0.0 ms 41b7257-source-se... cache GET https://compilersaysno.com/fonts/b81a45a-source-code-pro-regular.woff2 | status 200, cache hit, start 74.0 ms, total 0.0 ms b81a45a-source-co... cache DOMContentLoaded 31 ms DOMContentLoaded 31 ms load 35 ms load 35 ms Blocked DNS TCP TLS Send Wait Receive Cache DOMContentLoaded load
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.

Nothing new

Admittedly, none of precompiling CSS, inlining critical styles (2014), embedding fonts as data URLs (2016), precompressing files, and choosing irresponsible initial congestion windows (2018) is exactly new, but it is fun to put them together and marvel at earlier work from an era when 500 ms was a good load time to aspire to (2015). Even the cache warming was described and dismissed (2011). You can potentially trade the duplicate fetch for a lot of JavaScript and service-worker headaches (2018), too.

On the other hand, the next article now loads in under three frames. I should probably go write it.

  1. At this point I wonder if this website needs a WAP version, if only to reclaim the acronym.

  2. The previous design might have been ugly, but it used zero custom fonts, at least.

  3. 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.

  4. This footnote does not say what you expected, does it? I'm better than that.

  5. I smuggled the previously dynamically loaded italic font into the old total.

  6. 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!

  7. A tried-and-true strategy: if the benchmark does not support your narrative, just drop it, aka "noise".

  8. Which is so close to being a ten out of ten!

  9. This also triggers the sunk-cost fallacy, incentivizing the user to load at least one more blog post!

  10. Also I spent a lot of time making these waterfall charts fit and I just can't deal with timings exceeding 250 ms right now.

  11. Well, at least standards-conforming HTTP...