releasePerformance

Loading 5,900 icons to draw… 86?

Two days of performance work on Hangarworks, almost all of it deletion: an icon font that was 97% unused, a login system booting on pages nobody had signed in to, and an avatar served at 459 KB to be drawn at 34 pixels. Public pages went from 56 to 100.

Lighthouse measures how fast a web page is and scores it out of 100. The first useful thing it told me was that it could not make up its mind: the same page, measured twice in a row with nothing changed in between, scored 56 and then 90.

A page does not become twice as fast while you are looking at it. This is not Schroedinger’s website. Two days of work followed, and almost none of it involved writing anything. Nearly every fix was a deletion, and most of what got deleted belonged to somebody else.

The icons

I was loading the entire Tabler icon font from a third-party CDN, in the root layout, on every page. The font is 451 KB and contains about 5,900 icons. The app uses 86. I used to laugh, because in EverQuest II, someone made a targeting arrow that was 4000 polys when it could have been a 2d card. Who’s the card now?

The size wasn’t even the worst part. It was a render-blocking stylesheet on a different domain, so every visitor's browser stopped, looked up a hostname it had never heard of, opened a connection and waited, between 942 and 1,644 milliseconds, before drawing anything at all.

It is now a subset of the icons I actually use, served from my own domain and no longer render-blocking: 451 KB down to 12.7 KB. The build refuses to run if that subset is stale, so nobody can add an icon and ship a blank square where it should be.

Building it also turned up a bug that was already live. Tabler 3 removed the -filled class names that version 2 had, and I was still using one, so the "saved" state on the wishlist button had been rendering precisely nothing, in production, for an unknown length of time. Nobody reported it, which is its own small lesson about bug reports.

Firebase, on pages nobody had signed in to

The root layout wrapped every route in the authentication provider. The homepage, the FAQ, the privacy policy, every blog post. Opening a blog post booted the whole authentication stack, roughly 450 KB of machinery whose entire contribution to that page was deciding whether the button in the corner should say "Sign in" or "Open app".

One bit of information. Four hundred and fifty kilobytes.

It moved into the signed-in half of the app, and the public header now works out which word to show by reading a single flag from local storage. First-load JavaScript on the public pages fell by 143 KB, about 55%.

The first attempt missed one link, because the footer builds its links from a data file rather than spelling them out in the markup, and that single link was enough to drag the whole of Firebase back onto any page short enough to show the footer on load. It explained a 75 on a page where everything else scored 100, a discrepancy previously filed under measurement noise. The fix now works by route rather than by link, which is the difference between fixing the instances and fixing the category.

Pictures, at roughly nine hundred times the necessary size

The landing page screenshots were 2464 pixels wide and displayed at 640. Run through the image pipeline as AVIF, one went from 187 KB to 7 KB.

Inside the app it was worse, because there the images belong to the members. Avatars were plain image tags pointed straight at storage, so a photo uploaded at whatever resolution a phone produces was then sent, at full size, to every member of the org, in order to be drawn at 34 pixels across. One file was 459,338 bytes. At the size it is actually painted, it is 2,196. This is the kind of thing that Claude helps me find, because my webdev skills have somewhat atrophied since 2001, which was the last time I built a site of note that wasn’t in Squarespace.

The app was painting nothing at all

Every signed-in route took about seven seconds to put anything on screen. The total blocking time was 10 milliseconds.

Those two numbers together rule out the obvious explanation. Ten milliseconds means the browser was not struggling. It was not parsing, or executing, or laying out. It was waiting, for seven seconds, with nothing to do.

The app layout returned a bare loading spinner until authentication resolved, and because those routes are prerendered, that spinner was the HTML I shipped. The org page served a document whose body contained no visible text of any kind.

It now renders the navigation and a skeleton of the content immediately, so the first paint costs one round trip for HTML and CSS, the way it should. On the blueprints page, the first paint went from 4.04 seconds to 0.97.

Two smaller ones

  • The crafting planner jumped, a layout shift of 0.412, which in human terms is the list moving under your thumb just as you go to tap it. A one-line "Loading" message was collapsing into a full plan list. The space is reserved now.
  • Every dropdown was missing an accessible name, not merely the four the audit happened to reach. A screen reader announced them as, roughly, nothing. The shared components now require a label, so the next filter anybody adds cannot ship nameless.

Where it landed

Production, three-run medians, mobile.

PageBeforeAfter
Patch notes56100
FAQ56100
Blog Posts64100
Blog hub92100
Homepage89100
Blueprints6176

Best Practices in Lighthouse went from 74 and 75 to 100 across the public pages, and the homepage from about 1.1 MB to under 200 KB. Signed-in pages shed a further 347 KB of third-party JavaScript on every load.

The scores also stopped wobbling, which was great. A page that loads a third party is at the mercy of that third party's afternoon, so it scores 56 sometimes and 90 others. Remove the third party and the number remains blissfully inert. The inconsistency was not noise obscuring the problem. The inconsistency was the problem, waving.