← All writing
Craft · · 9 min

Blank on purpose

What a month of blocking one domain at a time turned up, and why the most expensive script on the site was the one somebody had paid for.

Performance Tooling

A client’s homepage sat blank for about a second and a half on a phone before it painted anything. It was doing that deliberately.

Not slow. Blank. The HTML had arrived, the stylesheet had arrived, the browser knew what the page looked like and was ready to draw it, and something up in the head was holding the whole body at opacity: 0 until a script from another company answered. The snippet doing it had been pasted in by hand, off the documentation of the tool they were paying to make the site convert better.

I only found it because I’d spent most of November going through every third party on that site one at a time. I only started doing that because somebody on their marketing team asked me a direct question and got a bad answer. They wanted to know whether the review widget was slowing the site down. I said probably, a bit. That’s not an answer, that’s a vibe. They’d have been within their rights to ignore it.

Right-click, block, reload

The technique that ends the argument is free, it’s in the browser you already have open, and it costs a few minutes per script.

DevTools does request blocking. Right-click any request in the network panel, block that domain, reload, and read the difference. It’s not an estimate off a vendor’s marketing page and it’s not a guess from a bundle analyzer. It’s the counterfactual, measured on the actual site. This is the page with the thing on it, and this is the page without.

a few minutes per script
1. Network panel, throttle to Fast 3G, CPU 4x slowdown
2. Reload, write down: total transfer, blocking time, first paint
3. Right-click the request, Block request domain
4. Reload, write down the same three numbers
5. Unblock, next one
Text

Two things make or break it. Throttle the processor as well as the network, because the processor half is where this stuff does its real damage and an unthrottled laptop hides it completely. And run every measurement three times, because these numbers are noisy and the first run will talk you into something wrong.

You end up with a table with a row per vendor and a cost in seconds. I’ve gotten more out of that table in a meeting than out of every principled argument about page weight I have ever made.

Kilobytes are the least of it

Before any of the results, the thing I’d want understood is that file size understates all of this, in about four ways.

They load more things. A tag container is maybe 30 KB, and then it fetches whatever somebody has configured it to fetch, at runtime, from wherever, and your build tooling has never seen those files and your bundle analyzer cannot know they exist. The 30 KB is the menu and not the meal.

JavaScript also costs more than its weight. 100 KB of image is decode work on a track of its own, where 100 KB of JavaScript has to be downloaded, parsed, compiled and run on the main thread, and on a mid-range Android that math is brutal in a way it simply isn’t on the machine you’re testing on.

Every new origin costs a connection of its own. There’s a lookup, then a connection, then a TLS handshake, all before a single byte of the actual script arrives, and on a high-latency mobile connection that’s a few hundred milliseconds per domain. That site was talking to 11 other companies.

And a fair amount of it blocks. Anything injected synchronously into the head stops the parser where it stands, which historically has been most analytics snippets and all of the category I’m about to be rude about.

Hiding the page to test the page

Most of the results were dull, which is the honest headline. Most third parties on most sites cost a bit, and if you took them off nobody outside the meeting would notice either way. One category is different, and it’s the finding I’d most like people to walk away with.

Client-side A/B testing and personalization tools are, by design, catastrophic.

The problem those tools have is flicker. If the tool loads asynchronously, the visitor gets the original page and then watches it turn into the variant a beat later, which looks broken, and looking broken is worse for the client’s numbers than not testing at all. So essentially all of them offer the same fix, which is a small synchronous snippet at the top of the head that hides the page until the tool has loaded and applied its variants. With a timeout on it, because the tool might never load. The defaults I’ve seen are measured in whole seconds.

So the tool bought to increase conversion shows every visitor a blank white page while a script comes down over their connection. If that request is slow, everybody waits. If it fails, everybody waits out the entire timeout and then gets the original page anyway. On the site I was measuring it was the single largest contributor to first paint by a distance, on every page, including all the ones with no test running on them.

I don’t think it’s a scandal. It’s an honest trade the vendor made, it’s documented in their own help pages, and given what they’re being asked to do inside a browser I’m not sure a better answer exists. But it does mean the tool spends a fixed cost on 100% of visitors to maybe gain a couple percent on a fraction of them. I have never once seen that math done by the person who bought it.

Somebody else’s bad afternoon

The other cost is the one that never makes it into the table, because it isn’t there on the day you measure.

Every one of these scripts is a dependency on another company’s uptime, sitting in your critical path, with nothing behind it. No contract, no status page anybody here is watching, no relationship at all in most cases.

A synchronous script from an origin that hangs will hang your page. Not fail quickly, hang, until the browser gives up, which is a long way past the point where the visitor has gone somewhere else. I once watched a site sit unusable for about 20 minutes because a font service was having a bad afternoon. There was nothing on our side to fix and nothing to do but reload it and wait. The client asked a completely fair question about why their site was down when nothing about their site had changed. I did not have a good answer for them.

async and defer fix most of that, cost nothing, and should be the default for anything that isn’t genuinely needed before the page renders, which is nearly everything on nearly every site. The one category that can’t be fixed that way is the one in the section above, because the blocking is the entire function.

So whose is this one?

I actually handed over a spreadsheet, which is an anticlimactic end to a month of work.

Every third party on the site, one per row. What it is, who asked for it, what it costs in seconds, and in the last column a person. Not a team. A person, because “marketing owns that one” is how a row survives review cycle after review cycle without anybody looking at it, where a name in a cell can be asked whether they still want the thing now that there’s a number sitting next to it.

Half the rows nobody could account for at all. Not in a sinister way, either. The person who added it left, or the campaign it was bought for ended in 2017 and the tag stayed behind, or it arrived bundled in a theme. There were two analytics scripts collecting more or less the same events, added about two years apart, and nobody still working there could tell me why either one was on the page. My sense is that’s just the normal condition of a site more than a couple of years old.

The part that actually moved anybody had nothing to do with the spreadsheet. I recorded the client’s own homepage loading on a throttled phone, twice, once with that one domain blocked, and played the two recordings next to each other on a call. Nobody argues with that. It’s the same reason a keyboard walkthrough done live lands where a written finding gets noted politely. It’s their site, it isn’t a gotcha, and everyone in the room can see the thing for themselves.

They canceled the subscription that week. I’d love to report that this works reliably and it does not. The same demo on a different project in the spring changed nothing whatsoever, because that time the person who had bought the tool was sitting in the room, and what I was playing back to them was their own decision.

What stayed

Most of it stayed, for what it’s worth. The analytics is small, it’s async, somebody genuinely opens it, and I’d rather spend the seconds there than pretend I’ve achieved some kind of purity. The consent platform stayed as well, which is its own circular little joke and a fight for a different month.

The week before I started any of this, I sat in a 40-minute argument about whether a 28 KB framework was too heavy for a project. It was a good argument. Everybody was being reasonable and technical and enjoying themselves, and there were real numbers on both sides. It was also 28 KB against the 470 KB of other people’s JavaScript I had measured on that same site the day before, so the whole thing was a fight over about 6% of the page’s script weight, and the other 94% never came up once.

I don’t think that’s stupidity. The framework is ours. So it feels like our decision and our responsibility, and ours is where it feels appropriate to apply rigor. The tag somebody in marketing added in March is weather. You don’t audit the weather, you complain about it and move on with your day, and it’s the same reflex that had me spending a day on a stylesheet while a 2 MB image sat at the top of the page. Everything above is really just a way of making other people’s code feel like it’s on your page too, which it is, and which the person on the phone waiting out a timeout already knows.

Read similar posts
10 min

It's never the prompt

The prompting turns out to be the least interesting part of my Claude Code setup, and most of what's actually doing the work is a handful of files I wrote once and then stopped thinking about.

8 min

Let the browser measure it

A site built last year with a perfectly good image pipeline was still sending the 1600px file to a phone, and it turned out to be the same missing attribute I wrote a whole post about in 2018.