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:
The framework baseline
Pinned as a regression alarm. If it moves, a dependency moved — that is useful information and it is not your doing.
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:
| Feature | Naive approach | What it is instead |
|---|---|---|
| Scroll progress bar | Scroll listener + state | A CSS scroll-driven animation |
| Reveal on scroll | IntersectionObserver | animation-timeline: view() |
| Pinned horizontal section | Scroll maths in JS | position: sticky plus a scroll timeline |
| FAQ accordion | State + ARIA | <details>, with no ARIA at all |
| Parallax background | Transform on scroll | position: fixed |
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:
@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:
Every image gets width and height
Or an aspect ratio. An image without intrinsic dimensions is a layout shift waiting for the network.
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.
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:
// 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 suite | Lighthouse | |
|---|---|---|
| Runs under | Reduced motion | Default motion |
| Catches | Structure, labels, contrast at rest | Contrast mid-animation, real timings |
| Blind to | Anything only true while animating | Most structural a11y |
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:
| Configuration | LCP | Performance |
|---|---|---|
| No globe | 665 ms | 100 |
| 702 dots | 710 ms | 100 |
| 1005 dots | 860 ms | 99 |
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
Measure your framework floor before setting any budget
Then track the floor and route-added JavaScript as separate numbers.
For every interactive feature, ask whether CSS already does it
Sticky, scroll timelines, details, fixed positioning. Most scroll effects need no script.
Self-host fonts with swap plus size-adjust
Swap alone trades CLS for LCP; the metric overrides are what stop it.
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.
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.
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.