I have measured the bytes this site ships, three different ways. I had never measured when anything appears. So: a PerformanceObserver installed before the document exists, over all 76 public pages of Nakodo, with the HTTP cache off.
await page.cdp("Network.setCacheDisabled", { cacheDisabled: true }); await page.cdp("Page.addScriptToEvaluateOnNewDocument", { source: ` new PerformanceObserver(l => { for (const e of l.getEntries()) { window.__m.lcp = e.startTime; window.__m.lcpEl = e.element ? e.element.tagName + "." + e.element.className : "(none)"; window.__m.lcpSize = e.size; } }).observe({ type: "largest-contentful-paint", buffered: true }); new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) { window.__m.cls += e.value; window.__m.shifts++; } }).observe({ type: "layout-shift", buffered: true }); `}); Two details that decide whether this works at all. addScriptToEvaluateOnNewDocument runs before any page script, which is the only way to catch the entries that fire in the first hundred milliseconds. And buffered: true hands you entries that were recorded before the observer existed, which is the flag people leave off and then wonder why LCP is sometimes zero.
These numbers are from my machine on a fast connection against a warm CDN, so read the absolute values as the best case and the relationships as the finding.
pages 76 LCP median 160ms range 124 to 448 FCP median 160ms range 124 to 448 TTFB median 16ms range 12 to 48 transfer median 44 KB range 36 to 51 (compressed HTML) DOM nodes median 869 range 663 to 1,478 requests 57 to 63: 26 JS, 2 CSS, 10 woff2 CLS exactly two distinct values on the whole site LCP is the same number as FCP on all 76 pages
Not close: the same value to within a millisecond, on every page, 76 for 76. Which is a strange result until you look at what the LCP element is:
H1 50 pages P 26 pages Every single one is text. There is not one image in the entire public site: no raster files, no next/image, all the artwork is inline SVG, and an inline SVG never wins the LCP candidacy here because the heading above it covers more area. So the largest contentful element is part of the first paint by definition: the HTML arrives, the text paints, and the biggest block of text was in that paint.
The home page is our worst page and still only 448ms, with the LCP element being the h1 that reads "Find partners who say yes." at 60,522 square pixels. The how-it-works page is a typical one: FCP 176ms, LCP 176ms, same element.
What that does is delete most of the LCP playbook. Preloading the hero image, fetchpriority, AVIF, responsive srcset, lazy-loading everything below the fold: none of it applies, because there is no hero image. The levers that remain are TTFB and how fast the first paint can happen, which means the practical LCP of this site is "when does the HTML arrive and can the browser paint it immediately".
The second half of that sentence is doing more work than it looks. Here are the font requests on one page, in milliseconds:
10 woff2 files, all starting at 63 to 64ms, all finished between 93 and 109ms FCP: 176ms Ten font files, fetched in parallel, all done 70ms before the first paint. That is why there is no font flash and no font-swap reflow anywhere in the CLS data: on a warm cache-disabled load from a nearby edge, our three families and 20 font-face rules land before the browser has anything to show. On a slower connection that ordering reverses, and the metric that moves is not LCP but the one nobody reports: the first paint happens in the fallback face and the real face swaps in afterwards.
The entire site has two CLS values
0 16 pages 0.000439 60 pages Sixty pages share one value to six decimal places, which means it is one element moving one fixed distance. It is:
{ "value": 0.0004388619457603785, "sources": [{ "tag": "DIV", "cls": "ml-auto flex items-center gap-1 sm:gap-2", "prev": { "x": 1005.38, "y": 14, "w": 172.62, "h": 36 }, "cur": { "x": 1063.45, "y": 14, "w": 114.55, "h": 36 } }] } The right-hand group in the site header gets 58 pixels narrower, and because it is ml-auto in a flex row, a narrower box means its left edge moves right. Nothing else on any of our 76 pages moves at all.
Why it moves, and why I am not going to stop it
The public pages are static. They are generated at build time and served from the cache, so the server rendering them cannot know whether you are signed in. The browser can, and the component that works it out is deliberately written to render the signed-out version first:
// Public pages are static, so the server can't say who is signed in. The // browser can: Supabase keeps the session in a readable cookie. It is only a // hint for which button to show; the proxy and the app check for real. const SESSION_COOKIE = /(?:^|;\s*)sb-[^=;]+-auth-token(?:\.\d+)?=/; const hasSession = () => SESSION_COOKIE.test(document.cookie); // false on the server and during hydration, so the static HTML always matches. function useSignedIn() { return useSyncExternalStore(() => () => {}, hasSession, () => false); } That third argument to useSyncExternalStore is the server snapshot, and hardcoding it to false is what keeps hydration from mismatching. The consequence is that the first committed render is always the signed-out header:
curl -s https://nakodo.app/how-it-works | grep -o 'ml-auto flex items-center[^>]*>.*' # two buttons: "Sign in" -> /login, "Start free" -> /login?mode=signup and in a browser with a session cookie, that becomes one button:
{ text: "Open the app", width: 114.55, buttons: ["Open the app", "Menu"] } Two buttons at 172.62 become one at 114.55. The 58 pixels are the difference between "Sign in" plus "Start free" and "Open the app".
So the measurement comes with a condition I should state plainly: the browser I measured in is signed in. An anonymous visitor gets the signed-out header from the server, the hint agrees with it after hydration, nothing changes, and their CLS is zero. The 0.000439 is the cost of being a customer who went back to the marketing site.
And it is a very small cost. The threshold for a good CLS is 0.1, so this is one two-hundred-and-twenty-eighth of the budget, for one element, once, on a 36px-high box. I am not reserving width for it or deferring the swap. What the number is actually good for is knowing where the design's cost landed: a static page that knows nothing about you buys its cacheability with exactly one post-hydration redraw, and I can now put a figure on that redraw instead of hand-waving it.
The figure is also a warning about the direction of travel. The shift scales with the width of the box that changes, so if that signed-in header ever grows an avatar and an account name, the same mechanism produces a much larger number, and at that point min-w on the container stops being paranoia.
Two things I got wrong before I had the data
I assumed the 16 zero pages were different. They are not. The shift happens whether or not it is recorded, and what decides the recording is a race: a shift that lands before the first paint has nothing to be measured against. One page recorded its shift at 149ms with an FCP of 176ms, another at 314ms with an FCP of 316ms. I ran one load per page, so that 60 against 16 split is a sample of a race rather than a property of those pages, and the honest version of the finding is "every marketing page does this, and a bit over a fifth of the time it happens early enough to be free".
I expected the big pages to be the slow pages. The home page is 1,478 DOM nodes against a median of 869, and 596 KB of HTML uncompressed, and it is the slowest at 448ms. But the guide pages carry two sideways-scrolling tables and a stack of FAQ entries and come in at the median, and transfer size across all 76 pages varies only between 36 KB and 51 KB compressed. So size is not what separates them. The likely cause, which I have not isolated, is layout work rather than bytes: the home page's heading sits inside an illustrated full-width plate, and 1,478 nodes have to be laid out before the paint that contains it.
If you want to run this on your own site, the snippet at the top of this post is the whole measurement. Install it before the document, pass buffered: true, and print the LCP element rather than just the number: on a site with no images, which element wins is a more interesting fact than the millisecond it won at.