Moolam front end performance Measured 2026-09-16 on this machine (Windows 11, Chrome 152.0.7977.83 headless), against a production build of packages/web served by `next start` on port 3311, and against the live site at https://moolam.vercel.app. Lighthouse 12.8.2, performance category only, mobile profile (four times CPU slowdown, throttled 4G) and desktop profile. node packages/web/scripts/perf/lighthouse.mjs --base http://localhost:3311 --profile both --runs 3 node packages/web/scripts/perf/bundle.mjs --base http://localhost:3311 --dist .next-wo66 node packages/web/scripts/perf/scroll-frames.mjs --url http://localhost:3311/en --gpu The columns: score is the Lighthouse performance score out of 100, LCP the largest contentful paint, TBT the total blocking time, CLS the layout shift, JS the JavaScript actually transferred over the wire for that page. One run of Lighthouse on this machine swings by up to a factor of two on the same build, so the third table is the middle of three runs per page. The first table is one run per page, which is how it was taken before the runner learned to take a median, and the tree also moved between the two because other work landed in parallel. Read them as two readings of the same product, not as the before and after of one change. The change this pass did make is to the adaptive tier of the film, which does not alter what a page loads, so it shows up in the frame table near the bottom rather than here. AS FOUND, local production build, one run per page -------------------------------------------------------------------------- page mobile desktop score LCP TBT CLS score LCP TBT CLS /en 55 4559ms 1763ms 0.000 94 1629ms 52ms 0.011 /en/verify 57 9042ms 732ms 0.000 88 2314ms 44ms 0.000 /en/studio 50 12949ms 416ms 0.296 84 2299ms 124ms 0.097 /en/explore 63 10233ms 474ms 0.000 89 2235ms 46ms 0.000 /en/passport/0xcdb2... 54 11235ms 813ms 0.000 87 2187ms 56ms 0.000 /en/agent/10249 51 11364ms 1029ms 0.000 93 1701ms 41ms 0.000 /en/docs 48 9608ms 1614ms 0.000 93 1669ms 94ms 0.000 JavaScript transferred, as found: 2465 kB on /en, 2171 to 2203 kB on the rest. AS FOUND, the live site, one run per page -------------------------------------------------------------------------- page mobile desktop score LCP TBT CLS score LCP TBT CLS /en 43 7562ms 1220ms 0.021 96 673ms 43ms 0.011 /en/verify 57 12738ms 357ms 0.000 89 2131ms 42ms 0.000 /en/studio 39 16427ms 410ms 0.296 83 2139ms 31ms 0.097 /en/explore 62 9941ms 445ms 0.000 87 2306ms 40ms 0.000 /en/passport/0xcdb2... 59 10850ms 410ms 0.000 76 3176ms 43ms 0.000 /en/agent/10249 57 16821ms 332ms 0.000 95 1466ms 39ms 0.000 /en/docs 64 7847ms 356ms 0.000 99 744ms 24ms 0.000 The passport page is the slowest desktop page on the live site because it is rendered on demand against the chain and the index. Its first byte took 8.8 seconds cold and 1.2 seconds warm on the local build, while every other page answered in 2 to 20 milliseconds from the prerendered copy. AT THE END OF THE PASS, local production build, middle of three runs per page -------------------------------------------------------------------------- page mobile desktop score LCP TBT CLS score LCP TBT CLS /en 60 8863ms 455ms 0.021 94 1618ms 37ms 0.011 /en/verify 67 11324ms 351ms 0.000 87 2482ms 41ms 0.000 /en/studio 49 13671ms 356ms 0.296 82 2784ms 77ms 0.097 /en/explore 67 9013ms 348ms 0.000 87 2246ms 109ms 0.000 /en/passport/0xcdb2... 66 7518ms 398ms 0.000 91 1692ms 138ms 0.000 /en/agent/10249 67 9088ms 351ms 0.000 94 1661ms 60ms 0.000 /en/docs 59 9018ms 649ms 0.000 94 1655ms 42ms 0.000 JavaScript transferred: 2456 kB on /en, 2162 to 2194 kB on the rest. It did not drop, and the reason is the next section. WHAT THE JAVASCRIPT ON A PAGE IS MADE OF -------------------------------------------------------------------------- Built once with source maps (ANALYZE=1), then every script the browser fetched was attributed back to the package it came from by walking those maps. The figures are wire bytes, so compressed. /en, 45 scripts, 1561 kB 189 kB three 173 kB @privy-io/react-auth 170 kB viem 157 kB next 115 kB zod 76 kB @base-org/account 56 kB ox 39 kB gsap 39 kB @react-three/fiber 37 kB libphonenumber-js /en/docs, 43 scripts, 1314 kB, a page with no wallet, no film and no diagram 173 kB @privy-io/react-auth 170 kB viem 157 kB next 115 kB zod 76 kB @base-org/account 56 kB ox 39 kB gsap 37 kB libphonenumber-js 35 kB jose 33 kB motion-dom 32 kB @noble/curves 28 kB @walletconnect/sign-client 28 kB @coinbase/wallet-sdk 26 kB @privy-io/js-sdk-core 24 kB @noble/hashes 16 kB @headlessui/react 13 kB ws 8 kB styled-components 8 kB @walletconnect/core 7 kB ua-parser-js 7 kB @msgpack/msgpack 7 kB @wagmi/core 6 kB react-device-detect 5 kB preact Added up, the wallet packages alone are 480 kB of the 1314 kB on a documentation page, and viem, ox and the noble libraries (258 kB more) are on it because components/providers/PrivyProvider.tsx builds a wagmi config at module scope. The provider wraps every route from app/[locale]/layout.tsx, so a reader who opens the docs, the explore list or an agent page downloads and runs a wallet they will never use. gsap (39 kB) is on every page for the same reason: LenisProvider imports it whether or not the page has a scroll animation. That split is the one change that would move the mobile scores, and both files that carry it, components/providers/PrivyProvider.tsx and components/providers/LenisProvider.tsx, are outside the file list of this work order. The numbers are written down here so the next order can be aimed at them. What was already right, and was checked rather than assumed: - mermaid is fetched only where a diagram is drawn. A docs page without one loads 43 scripts and 1312 kB, and /en/docs/developers/architecture loads 60 scripts and 1554 kB. - shiki never reaches the browser: code is highlighted while the page is rendered. - three, drei and fiber are on the home page only, behind a dynamic import. - The five non-Latin faces are one chunk each and only the locale in use asks for its own. An English page preloads four font files, all Latin. - Every face has a metric compatible fallback from next/font (Archivo Fallback, ascent-override 88.96 percent, size-adjust 98.7 percent, and the same for Inter and IBM Plex Mono), which is why CLS is 0.000 to 0.021 on six of the seven pages. - The plates in the film come through the image optimizer at 640 px wide and quality 70. The home page loads 25 pictures for 216 kB in total. The one page that shifts is the studio, at 0.296 on mobile and 0.097 on desktop. Lighthouse names the element: the receipt column, `aside.relative` in components/studio/Receipt.tsx, which has no reserved height and grows as the run fills it in. That file is outside this order. WHY MOBILE IS WHERE IT IS -------------------------------------------------------------------------- On /en the largest contentful paint element is the 160 by 63 lockup in the header, because the film below it is a dark canvas until WebGL paints. Its four phases on the mobile profile: 453 ms to first byte, 1354 ms of load delay, 421 ms of load time, and 6635 ms of render delay. The render delay is the main thread: 4.3 s of script evaluation, 6.2 s of main thread work in total, and 743 kB of JavaScript that the page never uses. None of it is the film. It is the shared bundle above, running before the browser is free to paint. THE FILM, MEASURED -------------------------------------------------------------------------- scroll-frames opens the home page in a headless Chrome, scrolls it with real wheel events for thirty seconds and samples every frame from inside the page. It now also counts WebGL draw calls from before the document exists, so the startup stall and the behaviour of a background tab are numbers rather than opinions. With the GPU, at the end of this pass, three runs: tier held at 0 for the whole run mean 4.2, 4.3, 4.3 ms, p95 4.3 ms worst frame after the first second: 21, 21, 29 ms worst frame in the first 8 seconds of the page: 211, 202, 204 ms, all of them at about 220 ms, which is hydration and not the scene: the first WebGL draw lands at about 510 ms draws while the tab was behind another tab: 0 Without the GPU, as found, three runs: run 1: held tier 0, mean 19.7 ms run 2: stepped 0 to 1 at 9.4 s, then to 2 at 22.3 s, mean per tier 20.7, 20.4, 20.9 ms run 3: stepped 0 to 1 to 2 to 3 at 26.2 s, ending on the still story worst frame after the first second: 34, 34, 50 ms Without the GPU, at the end of this pass, three runs: all three held tier 0 for the whole thirty seconds mean 18.3, 17.5, 17.9 ms worst frame after the first second: 34, 34, 34 ms worst frame in the first 8 seconds of the page: 100 ms in all three draws while the tab was behind another tab: 0 The other half of the rule, watched in a browser rather than in a test. The same page without a GPU in a 2400 by 1400 window, where a frame really does cost more than the machine has: tier 0 for 1.7 s at a mean of 42.3 ms stepped to tier 1, which measured 42.2 ms, no better and still under thirty frames a second handed over the still story at 4.8 s, which then ran at a mean of 19.0 ms As found, that machine would have been walked down through tiers 2 and 3 over about twenty seconds before reaching the same place. The middle line of the before runs is the finding. A software renderer paces every frame at about 33 ms whatever is on screen, so each step down bought nothing: 20.7 ms, then 20.4 ms, then 20.9 ms. The controller read that as still slow and kept shaving the picture until the film was gone and the reader was on the still page, on a machine that had been drawing the film at a steady thirty frames a second the whole time. components/story/tier.ts now judges its own step. The mean that caused a step down is kept, and once the new field has settled the two are compared. If the step did not buy back at least a tenth of the frame time, the field was never the weight: the plates go back and nothing is taken away again. The exception is a machine that is still under thirty frames a second with less on screen, and that one gets the still story at once rather than being shaved twice more on the way there. Both paths are pinned by tests in packages/web/test/story-tier.test.ts. IN PLAIN WORDS -------------------------------------------------------------------------- On a desktop the product is quick: 82 to 94 out of 100, and the film runs at 4 ms a frame with the whole field on screen. On a mid-range phone it is not: 49 to 67, because every page, including the ones with no wallet on them, downloads and runs about half a megabyte of wallet SDK and the chain client behind it before the browser is free to paint. A judge on a phone waits several seconds for the first real paint. A judge on a laptop without a GPU used to watch the film quietly take itself apart over twenty seconds and end on a still page. Now it keeps the film, because the controller checks whether shrinking actually helped before it shrinks again. AFTER THE WALLET WAS TAKEN OFF THE PAGES THAT DO NOT SIGN -------------------------------------------------------------------------- Measured 2026-09-16 on the same machine and the same way, against a production build of packages/web served by `next start` on port 3319. Same runner, same seven pages, middle of three runs per page. ANALYZE=1 NEXT_DIST_DIR=.next-wo69 npx next build NEXT_DIST_DIR=.next-wo69 npx next start -p 3319 node packages/web/scripts/perf/lighthouse.mjs --base http://localhost:3319 --profile both --runs 3 node packages/web/scripts/perf/bundle.mjs --base http://localhost:3319 --dist .next-wo69 What changed in the app: components/providers/PrivyProvider.tsx is no longer in app/[locale]/layout.tsx. A small client boundary, components/providers/WalletProvider.tsx, loads it on the three routes that write and nowhere else: the studio, the resolve console, and the lower half of a passport page where the edit and dispute panels live. The header's link to the studio no longer prefetches, because the router was pulling those chunks down behind every other page. LenisProvider stopped importing gsap, which no section in this app ever registered a ScrollTrigger with. mobile desktop page score LCP TBT CLS score LCP TBT CLS /en 87 3345ms 219ms 0.021 100 682ms 23ms 0.011 /en/verify 82 4884ms 89ms 0.000 99 1011ms 0ms 0.000 /en/studio 62 8130ms 531ms 0.000 89 2252ms 10ms 0.000 /en/explore 78 4892ms 222ms 0.000 98 1126ms 0ms 0.000 /en/passport/0xcdb2... 64 8424ms 470ms 0.000 94 1620ms 1ms 0.000 /en/agent/10249 88 3988ms 31ms 0.000 100 811ms 0ms 0.000 /en/docs 92 3437ms 40ms 0.000 100 708ms 0ms 0.000 The same seven mobile scores before this change were 60, 67, 49, 67, 66, 67, 59. Four pages are at or above the target of 85, three are not, and the misses are named below rather than rounded up. JavaScript from this app's own build, by page, before and after: /en 45 scripts 1561 kB -> 35 scripts 920 kB /en/verify -> 21 scripts 432 kB /en/explore -> 23 scripts 449 kB /en/agents -> 22 scripts 434 kB /en/agent/10249 -> 22 scripts 434 kB /en/docs 43 scripts 1314 kB -> 21 scripts 432 kB /en/studio -> 46 scripts 1288 kB /en/resolve -> 45 scripts 1271 kB /en/passport/0xcdb2... -> 44 scripts 1272 kB The documentation page was 1314 kB with 480 kB of wallet packages in it. It is now 432 kB with none: no @privy-io, no @base-org/account, no walletconnect, no jose, no libphonenumber-js, and viem down from 170 kB to 61 kB, which is what the client components that format an address and a hash actually use. Explore, agents, an agent page and verify read the same. Watched in a browser, /en, /en/explore, /en/agents, /en/verify and /en/docs open no Privy iframe and define no Privy global at all; /en/studio, /en/resolve and a passport page each open exactly one. The three pages that still miss 85 on a phone, honestly: - /en/studio 62 and /en/passport 64. Both need the wallet, and on those routes Lighthouse counts 2146 to 2164 kB of script because the SDK then fetches its own hosted code, the Coinbase and WalletConnect connectors among it, from other origins. The app's own share of those pages is 1272 to 1288 kB. A passport page pays it for two panels that a signed out reader never sees, which is the next thing worth attacking: load the panels themselves on demand rather than with the route. - /en/explore 78 and /en/verify 82 are not carrying wallet code at all. Their largest paint waits on the server: explore reads the whole index live while it renders, and cold that read took 42 seconds on this machine before it warmed. - /en still downloads about 130 kB of studio chunks in the background, because the closing card of the film links to the studio and the router prefetches it. Nothing on that page runs the code. One prefetch={false} in components/story/CloseCard.tsx would end it. That file belongs to another piece of work, so it was left alone. THE STUDIO NO LONGER SHIFTS -------------------------------------------------------------------------- CLS on the studio was 0.296 on mobile and 0.097 on desktop. It is 0.000 on both. The cause was not the receipt filling in. Measured in a headless Chrome at 412 by 823 with the CPU slowed four times, the studio recorded one shift, 0.2957 at 2930 ms, and its two sources were the receipt column and the footer, both pushed off the bottom of the screen at once. What pushed them was the column beside the receipt: the page paints a one line "opening" label while it works out whether anyone is signed in, then drops the 889 px sign in card into that space. The receipt was the thing that moved, not the thing that grew. So the studio now paints the sign in card itself while it waits, dimmed and inert, with the same wait label over it. Same element, same height, nothing under it moves. The card becomes live when the wallet answers. THE PASSPORT PAGE'S FIRST BYTE, AND WHY IT IS STILL RENDERED ON DEMAND -------------------------------------------------------------------------- Before: 8.8 seconds cold, 1.2 seconds warm. After, on this build: 1.40 seconds cold on a freshly started server, then 0.46 to 0.50 seconds. That is not a fix. Nothing in this pass touched what the page reads, so the difference is the index, the gateway and the RPC being warm today, and it will go back to seconds on a cold service. The hold that would have fixed it was written, built and reverted. With `export const revalidate` and an empty generateStaticParams the route is a static one, and the first request to it fails at runtime: Error: Page changed from static to dynamic at runtime /en/passport/0xcdb2..., reason: no-store fetch https://indexer.dev.hyperindex.xyz/ae097f1/v1/graphql /[locale]/passport/[id] Every visit returned HTTP 500. The index reads this page makes are no-store, so Next refuses to keep the page. The agent page can be held because its reads go through the cached "held" helpers in lib/chain and lib/data. Giving the passport page the same helpers means editing those files, which this pass was told not to touch, so the page was put back the way it was and the finding written down here. THE PASSPORT PAGE IS HELD NOW -------------------------------------------------------------------------- Measured 2026-09-17, same machine, production build served by `next start` on port 3349. Before: 8.8 seconds cold on the live site on 2026-09-16 morning, and 1.40 seconds cold on a freshly started local server that evening, 0.46 warm. What the failed attempt above was missing is the cache wrapper. The six chain reads behind the page are now one held read, heldPassportRecord in lib/chain/registry.ts, and the two index reads it makes are held per passport as well, so no read escapes a wrapper and Next keeps the page. The no-store option on the inner fetches stays: inside unstable_cache it does not opt the route out. The build now renders the real pictures in every locale, so the first visitor after a deploy is handed a finished page: /en/passport/0xcdb25d37... 200 in 0.0218 s x-nextjs-cache: STALE /en/passport/0xcdb25d37... 200 in 0.0031 s x-nextjs-cache: HIT /ta/passport/0xcdb25d37... 200 in 0.0078 s STALE, then 0.0030 s HIT The STALE on the first request is the build's own page, handed over while the minute-old reads are refreshed behind it. The server log carries no "Page changed from static to dynamic" line for either locale. A passport outside the build's list still renders on demand and is then held the same way, which is the honest cold number for this page today: /en/passport/0x0e940dfa... 200 in 1.2087 s x-nextjs-cache: MISS /en/passport/0x0e940dfa... 200 in 0.0030 s x-nextjs-cache: HIT Eighty passport pages were built, eight real pictures across ten locales, and twenty agent pages. The lists are read from the index at build time with a ten second cap: an index that will not answer costs one cold first visit, never the build. An edit, a challenge or a settlement calls refreshPassport in lib/passport/refresh.ts before it asks the page to read itself again, which drops that one passport's name with no stale window, so the person who made the change never gets the minute-old page. THE TWO STUDIO LINKS THAT STILL PREFETCHED THE WALLET -------------------------------------------------------------------------- Measured 2026-09-16 evening, same machine, same scripts, production build served by `next start` on port 3329. The film's closing card and the phone menu each linked to the studio with the router's default prefetch, so a reader of the home page, or anyone who opened the phone menu on any page, downloaded the studio's chunks in the background. Both links now carry prefetch={false} and load on the tap. Scripts over the wire, this app's own build: /en 35 scripts 920 kB -> 23 scripts 688 kB /en/docs 21 scripts 432 kB -> 21 scripts 432 kB (no studio link on it) /en/studio 46 scripts 1288 kB -> 46 scripts 1288 kB (it needs the wallet) Before any of today's work the home page carried 45 scripts and 1561 kB. THE HELD PAGES ON THE LIVE SITE -------------------------------------------------------------------------- Measured 2026-09-17 from Chennai with curl, 92 seconds after Vercel finished building commit 46abdbe, two requests per page, the first one being the first anyone made to that page on the new build. /en/passport/0xcdb2... first PRERENDER 1.00 s second HIT 0.47 s /en/agent/10248 first PRERENDER 0.98 s second HIT 0.47 s /ta/passport/0xcdb2... first PRERENDER 1.10 s second HIT 0.25 s The same first visit after a deploy cost 4.1 s on /en/agent/10248 in this morning's check and 8.8 s cold on a passport page on 2026-09-16, because each page was rendered on demand from a cold chain, index and gateway. Now the real pictures and the known agents are built with the site, and the first visitor is handed the built page. The half second that remains is the trip from Chennai to the region that holds the page, not rendering. THE LIVE SITE AFTER THE DAY'S FOUR CHANGES -------------------------------------------------------------------------- Measured 2026-09-17 against https://moolam.vercel.app on commit f68e2c1, ninety seconds after Vercel finished the build, with the same runner as every table above, middle of three runs per page. node packages/web/scripts/perf/lighthouse.mjs --base https://moolam.vercel.app --profile both --runs 3 What changed on the live site since the 2026-09-16 table: the wallet SDK loads only on the routes that sign (73942a8), a passport fetches it only on a device that has signed in before and its two writing panels bring their code with them (bad41f6, f68e2c1), and the passport page is built once and held with the real pictures and the known agents pre-rendered at build (46abdbe). mobile desktop page score LCP TBT CLS score LCP TBT CLS /en 94 2033ms 261ms 0.000 100 440ms 14ms 0.011 /en/verify 86 4050ms 101ms 0.000 100 771ms 0ms 0.000 /en/studio 79 4723ms 239ms 0.000 87 2483ms 1ms 0.000 /en/explore 84 4387ms 107ms 0.000 99 862ms 0ms 0.000 /en/passport/0xcdb2... 99 2104ms 25ms 0.000 100 496ms 0ms 0.000 /en/agent/10249 97 2545ms 11ms 0.000 100 798ms 0ms 0.000 /en/docs 99 1879ms 13ms 0.000 100 436ms 0ms 0.000 The same seven mobile scores on the live site on 2026-09-16, before any of it, were 43, 57, 39, 62, 59, 57 and 64. Five pages now sit at or above the 85 the perf order asked for. The two that do not, honestly: the studio at 79 carries the wallet because signing in is the point of the page, and Explore at 84 waits on the server reading the index, not on script. Layout shift is zero on every page on a phone. Scripts named by the live HTML, chunks downloaded and searched for the wallet: /en/passport/0xcdb2... 18 chunks, none carrying the SDK (one names the gate's own property, no library body) /en 13 chunks, none /en/docs 14 chunks, none /en/studio 28 chunks, five carrying it, as it must EXPLORE'S FIRST PAINT -------------------------------------------------------------------------- Measured 2026-09-18 on this machine against a production build of packages/web (NEXT_DIST_DIR=.next-wo79) served by `next start` on port 3409, Lighthouse 12.8.2 through the runner below, mobile profile, middle of three runs per page. The index held 36 passports that day, 14 of them real pictures. node packages/web/scripts/perf/lighthouse.mjs --base http://localhost:3409 --profile mobile --paths /en/explore --runs 3 BEFORE How it was served. /en/explore is built on every request. The page awaits searchParams, because the chips and the search term live in the address, and the route says dynamic = "force-dynamic" for the same reason. The build prints it in the dynamic list, "server-rendered on demand". It carries no x-nextjs-cache header and answers Cache-Control: private, no-cache, no-store. The pages it sits beside carry x-nextjs-cache: STALE and s-maxage=30. First byte. Cold, with the data cache emptied and the server just started, 3.94 s. Warm, 16, 16 and 20 ms over three runs. Every read the page makes is held for a minute, and a hold that has gone stale is handed over as it stands while it refreshes behind the reader: 70 seconds after a read, past the minute, the next request still answered in 18 ms and all 39 cache entries were rewritten over the three seconds after it was answered. So the wait was never the minute expiring. It was having nothing held at all, which is what the first visitor after a deploy gets. The largest paint on a phone. The element is the first plate's picture, ul.mt-6 > li > a.group > img.object-cover. Its phases: TTFB 453 ms, load delay 339 ms, load time 31 ms, render delay 4757 ms. The picture was on the wire by about 820 ms and was not painted until 5579 ms, because every plate is drawn by a motion component: the served HTML carries opacity 0 on it and it only rises in once the browser has downloaded and run the page. The img also carried loading="lazy", which the lcp-lazy-loaded audit failed. In the unthrottled trace behind the same run the picture painted at 797 ms. Above the fold at 412 px: two plate pictures, 384 px wide files at 7 to 12 kB, and the two brand lockups at 2 kB. The sheet fetched 12 of the 14 pictures, 141 kB in all. Mobile Lighthouse, middle of three: score 78, LCP 5579 ms, TBT 155 ms, CLS 0.000, JS 417 kB. WHAT CHANGED The opening view is held while the site is built. Explore stays a page built on the request: the chips and the search term live in the address, and a route that is cached cannot carry a query, so the filters keep paying for themselves. What the build now does is fill the hold behind the view nobody has pressed a chip on. lib/data/prerender.ts holds the three reads that view makes, and the route's generateStaticParams calls it and returns no address, so nothing about how the route is served changes. Everything stays held for the same minute, and a newly registered picture still lands on the next request after the minute is up. The first row is drawn without a motion component. The four plates that make up the first row on the widest screen enter with the CSS rise the rest of the server-drawn site uses, so they are in the first paint instead of waiting for the page to be run. Everything under them still enters on scroll as before. The two pictures a phone shows before a scroll are named in the head of the page, so the browser fetches them before it has read the grid; the other two in that row are not, because anything named there is pulled down ahead of the picture the paint is waiting for. AFTER First byte. First request to the page on a freshly built, freshly started server, nobody having opened it: 0.38 s, of which the route's own modules are most of it. The same build with the hold thrown away answers 3.68 s, which is the control: what the build holds is worth 3.3 seconds to the first visitor. Warm: 31, 42 and 39 ms. A prerendered page on the same cold process answers in 23 to 46 ms, so the page is now within about a tenth of a second of one that was built with the site. The largest paint. Still the first plate's picture, now inside li.rise. The lcp-lazy-loaded audit passes, the img no longer carries loading="lazy", and the head names two pictures. In the unthrottled trace it paints at 382 ms against 797 ms before. Mobile Lighthouse, middle of three: score 81, LCP 5111 ms, TBT 88 ms, CLS 0.000, JS 419 kB. Layout shift is zero at 412 px and at 375 px, where the plate is 166 by 208 and the file fetched for it is 384 px wide. What the page showed, all of it from the index: the opening view drew 14 real pictures and no test cards, the Test images chip drew 22 test cards, All drew the first 24 of 36, and a search for 0xf0 drew 2. Nothing on the page is a sample. WHAT STANDS IN THE WAY OF 92 AND 2.5 SECONDS, AND BY HOW MUCH The order asked for a mobile score of 92 or better with the largest paint under 2.5 s on this local build. It is not reached, and two thirds of the gap is the measurement rather than the page. The two pages Explore is meant to keep up with were run in the same session on the same build: page score LCP TBT CLS live on 2026-09-17 /en/explore 81 5111ms 88ms 0.000 84, 4387 ms /en/passport/0xcdb2... 84 3886ms 222ms 0.000 99, 2104 ms /en/agent/10249 88 3844ms 90ms 0.000 97, 2545 ms Every page scores about 15 points lower here than the same page on the live host. Lighthouse's mobile number is a simulation laid over a trace, and on localhost every script arrives before the first paint, so all 419 kB of it is counted against the paint. Nothing on this machine reaches 92 on any of these three pages. Against its neighbours on the same build, Explore is now 3 points and 1.2 s of simulated paint behind the passport page, where on the live host before this work it was 15 points and 2.3 s behind. Two things are left in it: a contact sheet fetches 12 to 14 pictures, 141 kB, where a passport page fetches one, and the grid ships every plate to the browser to be hydrated (main thread on this page: 1007 ms other, 613 ms script evaluation, 574 ms style and layout). Drawing the plates on the server and keeping only the load-more button in the browser is the next thing worth doing, and it is a bigger change than this order. Two runs of the same build with no code change between them scored 76 and 81, each the middle of three loads. Read a difference of a few points here as the machine, not the page. THE FIRST SECONDS OF THE HOME PAGE -------------------------------------------------------------------------- Measured 2026-09-18, same machine, production build (NEXT_DIST_DIR=.next-wo78) served by `next start` on port 3389, headless Chrome with the GPU off. Two new scripts, beside the two that were already here: node packages/web/scripts/perf/layout-shift.mjs --url http://localhost:3389/en node packages/web/scripts/perf/first-plate.mjs --url http://localhost:3389/en THE SHIFT AT A THIRD OF A SECOND IS GONE -------------------------------------------------------------------------- Every layout shift in the first six seconds, three runs at each size, before and after. The number is what Chrome scores; the phone's shift was always there and Chrome excuses it because the page's own viewport meta resizes the layout viewport within half a second of it. before 1440x900 0.00694 0.00694 0.00694 375x667 0.02549 0.02549 0.02549 (excused, counted here anyway) after 1440x900 0.00000 0.00000 0.00000 375x667 0.00000 0.00000 0.00000 no shift entry at all One element moved, and the script names it: div.-mt-[73px], the wrapper the home page pulls the film up under the floating bar with. 1440x900 1425x890 at 0,0 -> 1440x900 at 0,0 at 325 to 331 ms 375x667 375x650 at 0,0 -> 375x667 at 0,0 at 322 to 331 ms The markup asks for -73px and the rule in globals.css hands it the bar's real height, 63 px on a desktop and 56 px on a phone, but only when the body holds a [data-story] element. Nothing carried that attribute until the film itself mounted, so for the first third of a second the whole first screen stood 10 px too high (17 px on a phone) and then dropped. The box the film loads into now carries data-story="loading" (StoryStage.tsx), so the pull-up is right on the first paint and the film replaces a box of exactly its own size. Fixing that alone left one smaller shift behind, 0.00036 at 1440x900: the bar's own row re-centring by 7 px when the scrollbar went, because the page is one window of story plus a footer until the film arrives and hides the footer. The same rule now hides it while the film is still loading, for anyone who has not asked for less motion. A reader who has asked for less motion is left alone: they are getting the still page, which is long and keeps its footer. The film's look and motion are untouched. Twenty seconds of scrolled film on the same build: mean 19.1 ms, p95 33.4 ms, first WebGL draw 449 ms, the same two tiers this software renderer has always stepped through. HOW LONG THE FIRST PICTURE TAKES -------------------------------------------------------------------------- Nobody knew, because nothing on the page could say. Each plate now reports from its own onAfterRender the first frame it was drawn with a picture on it and enough opacity to see it (components/story/scene/drawn.ts), and leaves two performance marks: moolam:first-plate and moolam:half-plates. They are marks and nothing else: no code in the app reads them. first-plate.mjs waits for them, five loads per profile, browser cache off on every load, and reports the median. first picture half the field desktop before 0.54 s 0.57 s after 0.53 s 0.57 s mobile before 8.93 s 9.81 s after 8.06 s 9.17 s Desktop is 1440x900 unthrottled. Mobile is 375x667 with the CPU slowed four times and the network held to Lighthouse's slow 4G. The desktop target was 2.0 s and it is met. The mobile target was 4.0 s and it is missed by 4.06 s, and this is exactly what stands in the way: scripts 654 kB of this app's own JavaScript, the last one in at 7.06 s canvas the scene's canvas exists at 7.50 s picture the first plate is drawn at 8.06 s Every millisecond before 7.5 s is the film's own code arriving and running: React, three.js, drei and the scene. No change to the picture path can move it. What the picture path did cost is the 1.37 s that used to sit between the canvas existing and the first plate being drawn, and that is now 0.56 s. What gates the pictures themselves, measured rather than assumed: order the field hands its pictures out nearest and largest first, so the first requests belong to the plates that fill the most of the opening frame (components/story/scene/order.ts). concurrency the requests are not serialised behind each other: ten to fifteen are in flight at once, and the whole field is asked for inside 20 ms of the scene mounting. waiting nothing waits for the full set. Each plate draws the frame its own texture arrives: on the desktop profile the first picture is up 40 ms before half of them are. size fourteen pictures, 640 px wide through the optimizer, 2 to 39 kB each, 209 kB for the whole field. the tier costs nothing measurable: the tier is decided in the same frame the canvas appears (7498 ms, then tier 1 at 7597 ms). The one thing that was ours to fix on a phone: the four pictures nearest the opening camera are now asked for by the document itself, at the same address, the same width and the same CORS mode the texture loader will use, so they are already in the browser when the scene arrives. On the phone profile they are asked for at 0.60 s and answered by 4.2 s, where they used to be asked for at 7.66 s. The crossOrigin on those four links is load bearing: without it the loader's own request does not match the preload and the picture is fetched twice, which measured 14 requests and 237 kB against 13 and 201 kB, and 0.8 s later to the first plate. A first visit after a deploy, before the optimizer has read any of these pictures from the gateway, costs more than the medians above: 0.96 s to the first picture and 1.79 s to half the field on the desktop profile, against 0.53 s and 0.57 s once the optimizer holds them. THE DEV SERVER AND 127.0.0.1 -------------------------------------------------------------------------- next.config.ts now names 127.0.0.1 and localhost in allowedDevOrigins. The dev server reads it and nothing else does: it is not in a build and not on the deployed site. A page opened on either origin hydrates and the film runs, proved on a dev server of this pass's own on port 3399: http://127.0.0.1:3399/en canvas true, HMR connected, first plate at 635 ms http://localhost:3399/en canvas true, HMR connected, first plate at 749 ms EXPLORE ON THE LIVE SITE, AND WHAT DID NOT CARRY OVER -------------------------------------------------------------------------- Measured 2026-09-18 from Chennai with curl, 103 seconds after Vercel finished building commit 966d45b, the first requests anyone made to these pages on it. /en/explore first MISS 4.08 s second MISS 0.76 s /en/explore?pictures=test first MISS 0.51 s /en HIT 0.47 s /en/docs/concepts/rules-and-standards PRERENDER 0.93 s The opening view held while the site is built made the first request 0.38 s on a freshly started local server. On Vercel it did not: the first visitor after the deploy still waited 4.08 s, so what a build writes into its data cache is not what Vercel's running functions read. The second request shows the one-minute hold does work once the running site has filled it. What did carry over is the rest of the change: the first row drawn without the page's script and its pictures fetched first. The cure for the first visitor is to make the opening view a built page like the passport and agent pages, with the chips and the search left to the request, which is the next order for this route. EXPLORE AS A BUILT PAGE, LOCAL NUMBERS -------------------------------------------------------------------------- Measured 2026-09-21 on this machine against a production build of packages/web (NEXT_DIST_DIR=.next-verify) served by `next start` on port 3399. These are local production-build numbers. What the first visitor to the live site waits is measured after the deploy, because the last change to this route was worth 3.3 seconds locally and nothing at all on Vercel. WHAT CHANGED The view a bare /explore opens on is now a page the build makes, ten of them, one per language, and the host keeps each one and refreshes it behind whoever asks. The chips and the search term still live in the address, and an address that carries any of the three keys the page reads is drawn on the request by a sibling route, //explore/view. One rule in proxy.ts sends it there, as a rewrite, so the reader's address bar still says /explore and every link written before the route split in two still works. Both routes draw the same component. THE BUILD /[locale]/explore ten paths, prerendered /en /ta /hi and 7 more /[locale]/explore/view server-rendered on demand Proxy (Middleware) one rule, matcher /:locale/explore Revalidate on the stored page is 30 seconds, not the 60 the route asks for. The footer's status line is held for 30 in the layout every page shares, and Next takes the shortest hold in the tree. /en, the passport pages and the agent pages all read 30 for the same reason, so Explore now sits exactly where they do. THE FIRST REQUEST ON A FRESHLY STARTED SERVER /en/explore first request 0.012 s STALE, from the store /en/explore again 0.007 s HIT /en/explore?utm_source=.. 0.005 s STALE, the built page: unknown keys do not narrow /en/explore?view=cards 0.024 s drawn on the request /en/explore?view=test 1.424 s drawn on the request, nothing held yet /en/explore?q=0xf0 0.519 s drawn on the request /en/explore?agent=10248 0.540 s drawn on the request /ta/explore 0.006 s the built page, in Tamil Before this order the same first request was 3.94 s on a freshly started local server with nothing held, and 4.08 s on Vercel. The first sheet is in the HTML: the served page names fifteen pictures by title, "Ant and Elephants" and "Dhanushkodi Ghost Town Church Under Storm Sky" first, with four plates drawn without a motion component and two pictures named in the head. WHAT STILL WORKS FROM THE BROWSER Headless Chrome against the same server. Load more on the all view took the sheet from 24 plates to 37. The Test images chip navigated without a document reload, the address bar read /en/explore?view=test, the chip lit, and the first plate was a prove-it card. The search box put /en/explore?view=pictures&q=0xf0 in the address and drew one plate. The load-more action posted to the bare /en/explore address, the one the build made, answered 24 rows and a cursor in 0.021 s. Layout shift, scripts/perf/layout-shift.mjs, two runs at each size: 1440x900 CLS 0.00000 no shift at all 375x667 CLS 0.00000 no shift at all A FAILED READ IS NEVER KEPT IN A BUILT PAGE Two builds prove it, because a page that is stored for a minute must never be stored with an error in it. A whole build with the index address pointed at nothing (127.0.0.1:9): the build finished, and /[locale]/explore came out server-rendered on demand with no page stored for it at all. The live build next to it stored ten. So an index that is down at build time costs the ten pages their built copy and never the build. A build against a forwarder that was taken away afterwards: the page was stored while the index answered, then the index started refusing. Nine requests over a minute, across two stale windows, every one of them answered the same fifteen pictures in 5 to 29 ms. The server log shows the refreshes failing behind them, "moolam-explore-sheet ... Error: the Envio endpoint answered 502", and the stored page was never replaced by the error. An address that is drawn on the request still says what went wrong in words, because nothing it draws is kept. A PASSPORT PAGE THE BUILD DID NOT MAKE, LOCALLY Asked for by the order, changed by nothing in it. A seed card the build makes no page for, three requests to the same production server: first 1.094 s x-nextjs-cache MISS second 0.009 s HIT third 0.005 s HIT So the page is stored on this machine, and nothing in the page or its reads stops it being stored. The second MISS seen on the live site on 2026-09-18 is the host, not the page. Worth reading the x-vercel-id of the two requests there before anything in the page is touched: two requests answered by different regions each fill their own store. EXPLORE ON THE LIVE SITE AFTER IT BECAME A BUILT PAGE (2026-09-21, commit 6580b38, measured from India with curl) The first request to each address after the new build went live, then a repeat: /en/explore first x-vercel-cache PRERENDER ttfb 1.28 s (the same first request measured 4.08 s on 2026-09-18) /ta/explore first x-vercel-cache PRERENDER ttfb 1.29 s /ja/explore first x-vercel-cache PRERENDER ttfb 0.92 s /en/explore?view=cards first x-vercel-cache MISS ttfb 0.73 s (narrowed views are drawn on the request, as designed) /en/explore second x-vercel-cache HIT ttfb 0.44 s The first HTML carries the real picture titles ("Ant and Elephants", "Monsoon Fishing Nets Drying"). What this does not cover: a visitor far from the host's region, a phone on a slow network, or the time the pictures themselves take to arrive. The 1.3 s first hit includes the cold start of the one rewrite rule in front of this route; every later request is served from the host's cache. THE AGENTS BOARD BECAME A BUILT PAGE, AND EVERY FAILED READ GOT A WAY BACK (2026-09-21) Measured on this machine against one production build, NEXT_DIST_DIR=.next-verify, served with next start on port 3399. The dev server on 3210 was never touched. What the build listed: /[locale]/agents prerendered as static HTML for all ten locales (SSG) /[locale]/explore unchanged, ten locales /[locale]/explore/view unchanged, drawn on the request First requests to the fresh server, nothing warmed: /en/agents first 0.007 s the two agents, their trust scores and their names in the HTML /en/agents again 0.004 s /ta/agents first 0.020 s /ta/agents again 0.003 s /en/explore first 0.010 s Layout shift, scripts/perf/layout-shift.mjs, two runs at each size, against the same server: /en/agents 1440x900 CLS 0.00000 375x667 CLS 0.00000 no shift at all /en/explore 1440x900 CLS 0.00000 375x667 CLS 0.00000 no shift at all At 375x812 neither page scrolls sideways: document scrollWidth 375 against a window of 375 on /en/explore, /en/agents, /ta/explore and /ja/agents. The chip row still scrolls sideways inside itself, which is what it is for. THE WAY BACK FROM A 429 A local endpoint standing in for the index, answering 429 to every query, with a dev server on 3398 pointed at it (the index address is written into the bundle at build time, so a second production build would have been needed to point the built server at it, and this order is allowed one). The agents board, watched in headless Chrome for 84 seconds: page load "The index could not be read: the Envio endpoint answered 429." "Trying again in 10 seconds", counting down +10.4 s first try +20.0 s second try +40.1 s third try after that "Still unreachable. The registry itself is on Monad and the passport pages read it directly." The endpoint's own log, one line per query, is what those seconds are read from: the roster query arrives at 61.0 s (the page load), 71.4 s, 91.4 s and 131.5 s, and never again. Three tries, each wait twice the last, and the first wait is ten seconds rather than five because a 429 is the index asking for room. What this does not cover: the ten queries a single dev render makes of the same read. That multiplier is the dev server rendering the route once per locale and it is the same on Explore, which has been a built page since 6580b38. The built server answers /en/agents from the page it made, with no query at all. ================================================================================ THE STATED CHAPTER ON THE HOME PAGE, 2026-09-21 ================================================================================ The stated beat joined the real film. Measured on the dev server on port 3210 in headless Chrome with a GPU (tier 0 held in every run). No production build was made in this order. WHAT THE CHAIN SAYS, AND WHAT THE FILM DRAWS The film's focal passport today is 0xf08bea9e1429bee09065a3cd443f353096acf748c73113ac8696313da9a059d7, registered 2026-09-17T07:08:21Z. Read through the app's own /api/consent-at at second 1789996872: stated true, entry written 1789717128 (2026-09-18T07:38:48Z), writer 0x85a88Ca81ff5f681D96AB86fa60ccB8452A139a6, aiTraining notAllowed, aiGenerativeTraining notAllowed, aiInference notAllowed, dataMining notAllowed, no conditions. Drawn on /en, read back out of the running page at offset 0.375: title "Not for AI training." four marks aiTraining=notAllowed aiGenerativeTraining=notAllowed aiInference=notAllowed dataMining=notAllowed rule from REGISTERED SEP 17, 2026 to TODAY, notch labelled SEP 18, 2026 readout "STATEMENT WRITTEN ON MONAD SEPTEMBER 18, 2026, AND IN FORCE EVER SINCE" The home page is ten pages of scroll now, one more than the film it replaced: the scroller reports 11.00 screens against the classic cut's 10.00. THE BOXES, DESKTOP AND PHONE, IN THREE SCRIPTS /en 1440x900, offset 0.375: words x 859 to 1384 y 275 to 525, rule x 86 to 776 y 490 to 639, shot the whole frame. Overlap none. /ta 1440x900, offset 0.375: words x 894 to 1384 y 267 to 612, rule x 86 to 776 y 527 to 679. Overlap none, no sideways scroll. /ja 1440x900, offset 0.375: words x 978 to 1384 y 215 to 585, rule x 86 to 776 y 487 to 637. Overlap none, no sideways scroll. /ta 375x812, offset 0.375: picture band y 0 to 365, words y 386 to 780 (x 20 to 349), rule y 651 to 729. Overlap none, line by line. /ja 375x812, offset 0.375: picture band y 0 to 365, words y 386 to 782 (x 20 to 354), rule y 663 to 730. Overlap none, line by line. /hi, /ko and /zh at 375x812 end at y 718, 718 and 702. /en ends at 739. Two things were changed to get there. The Tamil caption first ran 45 px past the bottom of an 812 px phone, so the stacked drawing lost 12 px of its gaps and the Tamil chapter was written shorter. The chapter's title is balanced (text-wrap: balance) because the browser's own break left one Japanese character alone on a second line. FRAME TIME, THE NEW BEAT AGAINST THE SEAL, SAME FILM, SAME RUN stated 0.3342 to 0.4453 mean 4.8, 4.8, 5.6 ms p95 8.4 ms worst 20.8 ms sealed 0.2133 to 0.3342 mean 4.5, 5.7, 5.5 ms p95 8.4 ms worst 25.0 ms Median of three runs each: 4.8 ms against 5.5 ms. The new beat is not dearer than the beat before it. LAYOUT SHIFT AND THE FIRST PICTURE scripts/perf/layout-shift.mjs, /en, 3 runs each, before and after the change: 1440x900 CLS 0.00000, 0.00000, 0.00000 375x667 CLS 0.00000, 0.00000, 0.00000 scripts/perf/first-plate.mjs, desktop, 3 runs on /en: before 4.23 s (cold optimizer), 2.60 s, 1.97 s; median 1.97 s of the two warm runs, 2.60 s of all three after 2.29 s (cold optimizer), 1.81 s, 1.67 s; median 1.81 s The optimizer's own cache warmed during the session, so the honest reading is that the chapter costs the first picture nothing: the page's server work is unchanged, the one consent read behind it was already there. REDUCED MOTION AND NO WEBGL Both fall to the still page and both carry the chapter: the rule, the notch and the four marks all drawn finished at opacity 1.00, in English, Tamil and Japanese, at 1440x900 and 375x812. No overlap, no sideways scroll. THE WAY OUT IS UNCHANGED scripts/perf/story-reach.mjs on /en: one wheel tick moves the story and not the page; data-story-end after 19 ticks; two more bring the footer to bottom 900 of 900. Tab reaches the skip control at tab 10 and the film at 11; pressing skip lands on the footer with focus inside it. PageDown and Space move 1000 px, an arrow 167, End winds 2000 to 9000 and reaches the footer, Home comes back. Nine screens of travel, one more than before, as the cut asks for. WHAT THIS DOES NOT COVER A 375x667 phone. In Tamil the chapter runs 82 px past the bottom there, and so do the film's own older chapters: shared by 36 px, verified by 97 px and trusted by 163 px at the same size. That is the film's phone band, not this chapter, and nothing in this order changed it. ================================================================================ CONSENT ON EXPLORE, 2026-09-21 ================================================================================ MEASURED WITH Headless Chrome over the DevTools protocol against the dev server on port 3210. Device metrics overridden, layout-shift entries collected from a PerformanceObserver installed before navigation, read 3.5 s after load. SIDEWAYS SCROLL AND LAYOUT SHIFT 375x812 /en/explore scrollWidth 375, clientWidth 375, CLS 0 375x812 /en/explore?ai=open scrollWidth 375, clientWidth 375, CLS 0 375x812 /ta/explore?ai=closed scrollWidth 375, clientWidth 375, CLS 0 1440x900 /en/explore?ai=open scrollWidth 1440, clientWidth 1440, CLS 0 No layout-shift entry was recorded at all on any of the four, so the new chip row, the plate marks and the fourth count cost nothing that moves. TOUCH TARGETS AT 375 px All fourteen chips measure 44 px tall, the five new ones included: Any 53x44, Open to AI use 153x44, Not for AI training 192x44, Ask first 114x44, Nothing stated 153x44. The row scrolls sideways inside itself, which is why chips sit past the viewport edge while the document does not scroll. WHAT THIS DOES NOT COVER Nothing was measured on a real phone, and no first-paint timing was taken for this order: the AI-use sheet is the same one query per sheet the other chips make, with two more clauses in the where. ================================================================================ HOME SPEED, AND A CALIBRATION AGAINST THE LIVE SITE, 2026-09-25 ================================================================================ WHAT CHANGED The Verify link in the header and the Verify tab on the phone bar no longer fetch their page when they come into view. They fetch it on intent: a pointer over them, focus, or a touch. Explore does the same, because the live home page fetched Explore's payload and code by 2.4 s on a phone (one Lighthouse run of https://moolam.vercel.app/en, before any change). Agents and Docs were not fetched in the first five seconds on a phone and stay as they were. The switch is the one in Next 16.3.4's own guide (dist/docs/01-app/02-guides/prefetching.md, "Hover-triggered prefetch"): prefetch false until intent, then null. Vercel Analytics and Speed Insights mount once the page has painted and gone idle (requestIdleCallback, 4 s timeout, a 2 s timer where there is none), and their code comes in by import(), so it is a chunk of its own (0xyns2el6b-tm.js in this build) and no longer part of the layout's script. The Speed Insights script (/_vercel/speed-insights/script.js, byte for byte the same as va.vercel-scripts.com/v1/speed-insights/script.js, sha256 c7037207...564f5b) is web-vitals, and its observe helper at byte 2083 reads r.observe(Object.assign({type:e,buffered:!0},n||{})); FCP calls it with "paint" (byte 3365) and LCP with "largest-contentful-paint" (byte 9113), so a paint that happened before the script arrived is still reported. MEASURED WITH NEXT_DIST_DIR=.next-wo151 next build, exit 0, then next start -p 3485. node packages/web/scripts/perf/lighthouse.mjs --base --profile mobile --paths /en,/en/docs --runs 3 (middle of three per page) The local /en/docs is the calibration page: unchanged since the live build. Pass 1, 18:45 to 18:50 page score LCP TBT JS local /en 83 4525ms 61ms 339 kB local /en/docs 82 4969ms 39ms 464 kB live /en 69 2919ms 699ms 782 kB live /en/docs 72 3083ms 994ms 508 kB Pass 2, 18:51 to 18:55, the same four again page score LCP TBT JS local /en 82 4537ms 105ms 339 kB local /en/docs 82 4889ms 50ms 464 kB live /en 79 1720ms 569ms 781 kB live /en/docs 100 1717ms 18ms 508 kB Lighthouse's CPU benchmark on the last run of each page: pass 1 local 3624 and 3576, live 2796 and 1622; pass 2 local 2858 and 3268, live 2991 and 3532. The live docs run in pass 1 was taken with the machine at under half its usual speed (other sessions were running), which is where its 994 ms of TBT came from. Pass 2 is the steady one, and it agrees with the live docs reading of 2026-09-17 (99, 1879 ms). THE GAP, AND WHAT IT PREDICTS On /en/docs, pass 2: local 82 and 4889 ms, live 100 and 1717 ms. Live scores 18 points higher and paints 3.2 s sooner (LCP a third of the local figure). Local /en is 82 on the same pass, the same as local docs, so on that gap the new home page would score at the top of the scale live: 95 to 100, with a simulated LCP near 1.6 s. The old home page live is 79 in the same pass. On pass 1's gap (10 points the other way) the prediction would be 73; that gap came from a slowed machine, not the page, and is not the one to use. AGAINST THE EARLIER BUILD OF THE SAME DAY, SAME MACHINE local /en then: 84, LCP 4382 ms, JS 490 kB over 26 scripts. local /en now: 83 and 82, LCP 4525 and 4537 ms, JS 339 kB over 18 scripts. 151 kB and 8 scripts fewer, and no route prefetch at all on a phone in the first seconds (network log of the last run: the only _rsc requests are /en's own two). The local score did not move: the simulated LCP is still the h1 behind every script asked for before it, which is the local artefact the gap above measures. INTENT, IN A REAL BROWSER ON THIS BUILD Headless Chrome, guide already seen, route fetches recorded: 375x812, 7 s after load: none. Touch on the Verify tab: /en/verify. Pointer on the Explore tab: /en/explore. 1440x900, 7 s after load: /en/agents, /en/docs and one passport. Pointer on the header Verify: /en/verify. Focus on the header Explore: /en/explore. On a first visit the guide's scrim covers the page, so a pointer reaches nothing under it; focus still fetches. WHAT THIS DOES NOT COVER The analytics mount was not seen in production: the layout only mounts it where VERCEL=1, so a local build never draws it. The unit tests cover the idle wait and the timer. That Speed Insights still reports FCP and LCP rests on the buffered flag cited above, not on a live dashboard reading. The prediction is one calibration pair on one evening; a live reading after the next deploy is the proof.