Skip to content

Lumina / lab — rescue / 02

One landing page, built twice.

The same shop, built badly and then properly. Same content, same design, same throttled profile, same method — measured live in your browser below, and corroborated by committed Lighthouse runs.

Largest Contentful Paint
before 8,815msafter 830ms

10.6× faster

First Contentful Paint
before 4,189msafter 830ms

5.0× faster

Total Blocking Time
before 359msafter 125ms

2.9× faster

What this isInvented brand, deliberately slow half, not indexed

Kettlefield Preserves is invented, and both halves are ours. Neither is a real business and neither is indexed. The before build exists to fail Core Web Vitals on purpose, so it is labelled a simulation on its own page, sandboxed in the frame below, kept off every analytics and field-data path, and served X-Robots-Tag: noindex, nofollow.

01 — measured live, in your browser, right now

Your session, not ours.

Both frames are the real pages, running on your hardware and your connection. The figures come from PerformanceObserver and web-vitals inside each frame. Reload or scroll them and the numbers change, because your session changed. CLS is judged against the programme budget of 0.05.

What an embedded frame cannot measureLCP and INP do not report in a subframe — measured, not assumed

Two of the three Core Web Vitals are unavailable in here, and not for want of trying: largest-contentful-paint and Event Timing (the source of INP) both decline to report in a subframe. We measured it rather than assumed it — the same page loaded embedded and then as a top frame, with identical observers and five real clicks each, gave 0 LCP entries against 2 and 0 event-timing entries against 5, while a click listener inside the embedded frame counted all five. So those two rows say “not observable here” and link out to the build in its own tab, where they are real; section 03 measures both under a fixed profile. Long-task counts are shared rather than per-frame, because same-origin frames share one main thread. Everything else is per-frame and genuinely yours.

Before — as it arrived

A simulation, intentionally slow. Scroll inside it.

TTFB
FCP
CLS
not yet observed
Long tasks
Shared figure — same-origin frames share one main thread, so this is not separable between the two.
Dropped frames
Per frame, from its own requestAnimationFrame cadence; assumes 60Hz. Not a like-for-like comparison between the two: the after build runs a continuous shader animation by design, so its loop is doing work the before build's is not.
LCP
not observable herelargest-contentful-paint does not fire in an embedded frame — measured, 0 entries embedded against 2 as a top frame. Open this build in its own tab, or see the committed Lighthouse runs below.
INP
not observable hereSame reason: Event Timing reports nothing in a subframe. Measured with five real clicks — the frame's own click listener saw all five, the event-timing observer saw none.

Open before in its own tab ↗ — where it is the main frame, so LCP is measurable.

After — the Lumina rebuild

Same content, same design. Scroll inside it.

TTFB
FCP
CLS
not yet observed
Long tasks
Shared figure — same-origin frames share one main thread, so this is not separable between the two.
Dropped frames
Per frame, from its own requestAnimationFrame cadence; assumes 60Hz. Not a like-for-like comparison between the two: the after build runs a continuous shader animation by design, so its loop is doing work the before build's is not.
LCP
not observable herelargest-contentful-paint does not fire in an embedded frame — measured, 0 entries embedded against 2 as a top frame. Open this build in its own tab, or see the committed Lighthouse runs below.
INP
not observable hereSame reason: Event Timing reports nothing in a subframe. Measured with five real clicks — the frame's own click listener saw all five, the event-timing observer saw none.

Open after in its own tab ↗ — where it is the main frame, so LCP is measurable.

Render path — the after build’s WebGL hero

Read from the live WebGL context above by wrapping the real drawArrays, compileShader, linkProgram, bufferData and texImage2D calls — nothing here is estimated. The before build has no WebGL context, so there is nothing to trace on that side.

Waiting for the after frame’s shader to report. It is a lazily-loaded chunk, so it starts after that frame has hydrated; if nothing appears, this browser has no WebGL2 context and the panel says so rather than inventing a figure.

02 — what changed, and what each change bought

4 fixes, each with a number behind it.

01

Render-blocking CSS and JavaScript, and the bundle behind it

page-level attribution

First Contentful Paint 4,189ms → 830ms, and initial JS 148.0 kB → 108.9 kB gzip

What was wrong, and what we did
What was wrong
A carousel stylesheet in a plain <link>, then jQuery and its plugin as classic <script> tags with no defer — the parser stops four times before it can paint. Behind them, lodash imported wholesale for one string operation and Moment with every locale for one date.
What we did
The whole legacy stack removed rather than deferred. The gallery is a native CSS scroll-snap row, the date comes from Intl.DateTimeFormat, and route CSS is inlined into the HTML rather than fetched. Nothing on the critical path that is not the page.

How it was measured. Lighthouse CI, median of 3 runs, mobile + devtools throttling + 4× CPU — the reports in section 03. Bundle figures from `pnpm perf:assert`, gzip level 9, measured off .next/.

02

An autoplaying background video as the hero

page-level attribution

Largest Contentful Paint 8,815ms → 830ms — 10.6× faster on the same profile

What was wrong, and what we did
What was wrong
A 2.6 MB looping MP4 behind the headline, autoplaying, with a 237 kB JPEG poster — competing for bandwidth with the content a visitor came for, and pushing the LCP candidate behind itself.
What we did
A hand-written WebGL fragment shader: one full-screen quad, 48 bytes of geometry, no textures at all — the noise is computed in the shader, two octaves, warped so the light bends. 30fps at one device pixel per CSS pixel, because the drift takes a minute to cross the hero and nothing in it has an edge 2× would resolve. Lazy-loaded after hydration, because a decorative background does not belong on the critical path. The hero photograph is now the LCP element, inlined as a data URI.

How it was measured. Same Lighthouse runs. The render-path panel in section 01 measures the shader itself: draw calls, shader compile and link time, and real geometry and texture bytes, read from the live WebGL context.

03

A blocking font import, and images at camera resolution

isolated measurement

The same photograph, requested by the same page: 304 kB of JPEG on the before build, 3.9 kB of WebP on the after — and 2.98 MB of camera-resolution photography no longer sent at all

What was wrong, and what we did
What was wrong
The display face pulled in with @import inside a stylesheet — a round trip the browser cannot discover until the stylesheet it is written in has arrived — with font-display: swap and no fallback-metric matching, so every heading reflows when it lands. Product photographs served as 3600×2400 JPEGs to a 412px-wide phone.
What we did
next/font self-hosts the face at build time and computes the size-adjust and metric overrides that make the fallback occupy the same box, so the swap moves nothing. Product images go through next/image with explicit dimensions; the hero reserves its box with an explicit aspect-ratio.

How it was measured. Both byte figures are one request each, made against the built app on the same Linux runner the Lighthouse numbers come from: /_next/image?url=/source/damson-jam.jpg&w=640&q=75 returns 3,950 bytes of WebP, and the file it is built from is 304,329 bytes. The 2.98 MB is the ten source JPEGs on disk, which is what the before build sends. Marked isolated because those are direct measurements of this fix and nothing else.

04

Four unthrottled scroll handlers that desynchronise

page-level attribution

Total Blocking Time 359ms → 125ms

What was wrong, and what we did
What was wrong
A reveal effect, a shrinking nav, a back-to-top button and a hero parallax, each with its own window scroll listener — none passive, none throttled, none batched. Each reads layout with getBoundingClientRect and then writes it, so one scroll event forces four synchronous reflows, and the cost scales with the page: the reveal handler re-measures every match, which is twenty-odd elements once there are nine products and a six-entry journal.
What we did
Zero scroll listeners. The reveal is a CSS scroll-driven animation on animation-timeline: view() and the hero parallax one on animation-timeline: scroll(); both run on the compositor and animate only opacity and transform. Journal entries also carry content-visibility: auto, so an entry not yet scrolled to is never laid out, styled or painted. Both animations are declared only under prefers-reduced-motion: no-preference, so a visitor who asked for less motion gets the content present and still.

How it was measured. Total Blocking Time from the same Lighthouse runs, which measures each build separately. The live panel shows long tasks too, but as a SHARED figure — same-origin frames share one main thread, so a long task from either build is observed by both and cannot be attributed to one. That is why this claim rests on the Lighthouse number rather than the live one.

Limits of these figures — 5 notes3 of the 4 rows are page-level, not isolated to one fix
  • Three of the four rows are marked page-level: their figure is part of the whole-page before/after delta rather than a number measured by changing only that one thing. Isolating each fix would need four separate one-variable-at-a-time builds, each with its own three Lighthouse runs. That was not done, so the rows say so instead of implying a precision they do not have.
  • Both builds measure a Cumulative Layout Shift of 0.0007, so there is no CLS row above — there is no delta to claim. Neither figure is a mistake, and they are almost nothing for opposite reasons: the before build’s render-blocking scripts delay first paint so long that every image has already arrived before anything is painted, leaving nothing to shift, while the after build reserves every box in the stylesheet. It is a good illustration of why one metric is not a diagnosis — the catastrophically slow build passes the measure people reach for first.
  • Lighthouse cannot measure INP, so it is absent from the lab table and present in the live panel. INP needs a real interaction from a real visitor; Total Blocking Time is the closest lab proxy, and it measures main-thread work that would delay an interaction rather than an interaction that happened.
  • The live before build is less bad than these figures say, and that is worth stating rather than hiding. This host optimises files in the document root by itself: the 3600×2400, 4:4:4 photographs this repository ships arrive in a browser as 1600px, 4:2:0 JPEGs — 41 kB where the file on disk is 304 kB, measured against three of them on the live site. The figures above are taken against the built app with no such layer in front of it, because that is the only way to compare the two builds rather than the two builds plus one platform feature. It is also the more useful comparison: a host that shrinks your images has not deferred your render-blocking scripts, shrunk your 2.6 MB video, removed your four scroll handlers or reserved a single layout box — and those are what the eight and a half seconds are made of.
  • Every figure above was captured on Linux, the same platform that serves this page, and that is not incidental. An earlier set was taken on the maintainer’s Windows machine, where Next’s image optimizer silently returns the original file instead of a resized one — 304 kB of JPEG where the server returns 3.9 kB of WebP. The after build was being credited with a page nearly ten times heavier than the one it actually serves, understating the very gap this teardown exists to state. Measure where the code runs.

03 — lab corroboration, from committed runs

Lighthouse CI, on both halves, on the same profile.

Not live. Produced by @lhci/cli against real production builds, three runs each, with the JSON committed — so this table is a read of a file, not a re-derivation. The after build’s numbers are also an assertion: CI fails if LCP, CLS, TBT or the accessibility score regresses past budget.

Median of 3 runs per build, chosen by Lighthouse CI. Captured 2026-08-11 with Lighthouse 12.6.1. Profile: mobile, 412×823 @1.75x, devtools throttling, 4× CPU slowdown, 562.5ms request latency, 1475 kbps down.
MetricBeforeAfterBudget
Largest Contentful Paint8815ms830ms1800ms
Cumulative Layout Shift0.00070.00070.05
Total Blocking Time359ms125ms200ms
First Contentful Paint4189ms830msnot budgeted
Performance score0.490.98not asserted
Accessibility score111.0, asserted
Two things this table deliberately does not doNo composite score assertion, and no lab INP
  • It does not assert a composite performance score, which is why that row says “not asserted”. A composite can sit in the green while the one metric a visitor feels is failing, carried by the other five. The gate is the individual metrics. Accessibility is the exception, because every audit in it is a defect rather than a trade-off.
  • It does not report a lab INP, because Lighthouse cannot measure one. The INP readout in section 01 is real and comes from your own interaction; Total Blocking Time is the closest lab proxy and a different measurement. A passing TBT does not prove a passing INP.