Skip to content

Writing

The cost of JavaScript: what a 100/100 Lighthouse score actually requires

A framework floor you cannot budget below, four techniques that move the number, and the measurement discipline that keeps it there. Written from a site that scores 100/100/100/100 with zero route-level JavaScript.

7 min read

A perfect Lighthouse score is not a goal in itself — it is a proxy, and like every proxy it can be gamed. But the work required to earn one honestly happens to be the same work that makes a site fast for real people on real connections, which is why it is worth doing properly.

This is what it took on a site that now reports 100/100/100/100 with LCP under 800ms, CLS under 0.001 and zero route-added JavaScript on most pages.

Start by measuring the floor you cannot go below

The first thing to establish is what your framework costs before you write a line of application code.

So track two numbers, not one:

  1. The framework baseline

    Pinned as a regression alarm. If it moves, a dependency moved — that is useful information and it is not your doing.

  2. Route-added JavaScript

    Everything above the floor. This is the number you actually control and the one worth defending in review.

Measure real transfer size in a browser rather than parsing build manifests. Manifest shapes change between bundler versions, and a budget check that silently stops working is worse than no budget check.

The largest win is not shipping the JavaScript at all

Most performance advice is about making JavaScript cheaper. The bigger lever is deciding it is not needed.

Three things on this site that look like they require client code and do not:

FeatureNaive approachWhat it is instead
Scroll progress barScroll listener + stateA CSS scroll-driven animation
Reveal on scrollIntersectionObserveranimation-timeline: view()
Pinned horizontal sectionScroll maths in JSposition: sticky plus a scroll timeline
FAQ accordionState + ARIA<details>, with no ARIA at all
Parallax backgroundTransform on scrollposition: fixed
Each of these was a client component first. Every one is now CSS or markup, and behaves identically.

An animation library was removed entirely partway through this build. It cost 45 kB for one section, and every part of it turned out to be position: sticky plus a CSS scroll-driven animation. Deriving the travel distance in CSS also fixed an 800px overshoot the JavaScript-measured version had.

The trap that comes with CSS-driven reveals

A reveal implemented with an animation library serialises initial into the server-rendered HTML — so the page ships at opacity: 0 and a reader without JavaScript sees nothing. The CSS version has the inverse risk: declare the animation outside the reduced-motion guard and somebody with motion turned off gets a blank page. The rule that resolves both is that the base rule must be the animation's finished state, with the animation declared only inside prefers-reduced-motion: no-preference.

What actually moves each metric

LCP: it is almost always the font or the image

Largest Contentful Paint is usually text, and text waits for a font. Two decisions cover most of it:

css
@font-face {
  font-family: 'Display';
  src: url('/fonts/display.woff2') format('woff2');
  font-display: swap;   /* render immediately in the fallback */
  size-adjust: 96%;     /* and make the fallback the same size */
}

font-display: swap gets text on screen immediately. size-adjust and the metric overrides are what stop the swap from causing a layout shift — which is the trade most people make without realising they are making it.

Self-host. A font from a third-party origin costs a DNS lookup, a TLS handshake and a connection before a byte of it arrives, and preconnect only partially recovers that.

CLS: exactly zero is not attainable, and that is fine

The honest target is not zero.

The rest of CLS is reserving space:

  1. Every image gets width and height

    Or an aspect ratio. An image without intrinsic dimensions is a layout shift waiting for the network.

  2. Animated numbers reserve their final width

    A counter animating from 0 to 46,000 goes from one digit to six. On this site that measured CLS 0.021 against a 0.001 gate — fixed by rendering a hidden twin holding the final value, which reserves the width while the visible one counts.

  3. Nothing is inserted above existing content after load

    Banners, notices and consent bars belong in the initial HTML or fixed to the viewport.

INP: the work has to be smaller, not faster

Interaction to Next Paint measures the whole interaction — input delay, processing, and the paint that follows. The usual culprits are a long task blocking the main thread when the click lands, and a state update that re-renders more than it needs to.

The structural fix is the one from the previous section: if an interaction does not need JavaScript, it cannot have an INP problem. A <details> disclosure has no input delay because there is no handler to run.

For interactions that genuinely need script:

tsx
// Keep typing responsive while an expensive filtered list re-renders behind it.
const [query, setQuery] = useState('');
const deferred = useDeferredValue(query);
const results = useMemo(() => search(corpus, deferred), [corpus, deferred]);

And do not hijack the browser's own scheduling. Smooth-scroll libraries are the clearest example — they take over native scrolling, which is one of the few things a browser already does off the main thread, and hand it back to JavaScript.

The measurement discipline

Two tools, and they disagree usefully.

axe in the e2e suiteLighthouse
Runs underReduced motionDefault motion
CatchesStructure, labels, contrast at restContrast mid-animation, real timings
Blind toAnything only true while animatingMost structural a11y
Both suites are blind in a way the other is not. Running only one is how a real defect survives.

That difference is not theoretical. Lighthouse measured body text on one page at 2.85:1 because the element was caught part-way through a fade — the axe suite could not see it, because axe runs under reduced motion where there is no animation to be caught halfway through.

What we measured while building this

Real figures from this build, with the conditions attached — desktop preset, production build, two to three runs each:

ConfigurationLCPPerformance
No globe665 ms100
702 dots710 ms100
1005 dots860 ms99
The globe on the home page is 702 CSS-positioned dots. The count was chosen by measuring, not by taste.

Every dot is an element laid out and transformed in 3D before the frame carrying the headline can present. That is the whole reason the count is 702 and not a rounder, nicer number — and it is why raising it requires re-measuring rather than a judgement call.

The checklist

  1. Measure your framework floor before setting any budget

    Then track the floor and route-added JavaScript as separate numbers.

  2. For every interactive feature, ask whether CSS already does it

    Sticky, scroll timelines, details, fixed positioning. Most scroll effects need no script.

  3. Self-host fonts with swap plus size-adjust

    Swap alone trades CLS for LCP; the metric overrides are what stop it.

  4. Reserve space for anything whose size changes

    Images, counters, embeds. A hidden twin holding the final value is the cheapest fix for a changing number.

  5. Run axe and Lighthouse, and treat a disagreement as a finding

    Twice on this project the two disagreed, and both times the automated one was blind.

  6. Write the conditions next to every number

    Otherwise it cannot be compared to itself later, and it is not evidence.

Start here

Tell us what you are building

Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.