← Writing

How We Built a Marketing Site That Scores 99 on Lighthouse

We just rebuilt this site. The old one was competent and forgettable; the new one is a “press sheet” that we hope makes people stop scrolling. What surprised us is that going more visually ambitious made the site faster, not slower. It scores 99 on Performance, 100 on Accessibility, paints its largest element in 2.0 seconds on a throttled phone, and ships less JavaScript than a single tracking pixel.

Here is exactly how, in the order the wins mattered.

Ship HTML, not a framework

The site is built with Astro. Every page is rendered to static HTML at build time. There is no client-side router, no hydration waterfall, no framework runtime sitting between the visitor and the text they came to read. The only JavaScript that reaches the browser is the mobile menu, the scroll reveals, the live product demos, and a small analytics module — all plain TypeScript, not a component library, and under 2 KB gzipped in total.

That last item is worth a note, because we got it wrong once. Adding product analytics originally meant dropping in the vendor’s SDK, which pulled a 104 KB library from a third-party CDN to autocapture pageviews and undifferentiated clicks — roughly fifty times the weight of everything else on the page, to answer questions we weren’t actually asking. We replaced it with about half a kilobyte that posts five named conversion events straight to the analytics HTTP API. The lesson generalises: an SDK is a default, not a requirement, and the useful part of most of them is one HTTP call.

The number that makes the point:

Measured on the built output. Your mileage varies, but not by two orders of magnitude.
ApproachJS shipped (gzip)Time to interactive
Typical React marketing site120–300 KBFramework must boot first
This site (Astro, static)Under 2 KBHTML is already interactive

If your marketing site renders behind a client framework, you are paying to download, parse, and execute code whose only job is to reproduce HTML you could have shipped directly. For an app, that trade can be worth it. For a page whose job is to be read, it rarely is.

Self-host the fonts, then preload them

This was the biggest single win, and it is the one most sites get wrong. We use three typefaces — Fraunces for display, General Sans for body, Space Mono for labels. The obvious way to load them is a <link> to a font host. That link is render-blocking: the browser has to open a connection to another origin, download a CSS file, parse it, and only then discover the actual font files.

We moved all three families onto our own origin as woff2 and preloaded the two that appear above the fold:

<link rel="preload" href="/fonts/fraunces-var.woff2" as="font"
      type="font/woff2" crossorigin />

One detail cost us an afternoon: the font host served an empty stylesheet to our headless test browser, so a face silently failed to load and we caught it only because a screenshot looked wrong. Self-hosting removed that failure mode entirely — the files are ours, versioned in the repo, and can’t disappear behind someone else’s CDN rules.

Express depth with ink, not blur

The old design leaned on glassmorphism: translucent cards, backdrop blur, soft drop shadows drifting under everything. Blur is one of the most expensive things you can ask a browser to paint, and it has to repaint on scroll.

The new design has a single elevation rule: a hairline border plus a hard offset shadow with zero blur radius, like one sheet of paper resting on another.

--shadow-sheet: 4px 4px 0 rgba(25, 23, 19, 0.08);

It’s cheaper to paint, it never repaints on scroll, and — this is the part worth internalizing — it looks more deliberate, not less. Choosing a distinctive look and choosing a fast one turned out to be the same choice.

Animate transform and opacity, nothing else

Every animation on the site — the headline that prints from four offset color plates, the scroll reveals, the button hovers — runs on transform and opacity only. Those two properties are handled by the compositor and never trigger layout or paint. Animating width, top, or margin forces the browser to recompute the page geometry on every frame; animating transform does not.

That single rule is most of what kept cumulative layout shift at 0.003. The rest is reserving space: explicit dimensions on images, and preloading the display font so text doesn’t jump when the real face swaps in.

What the discipline bought

MetricTargetResult
Performance (mobile)≥ 9599
Accessibility100100
Largest Contentful Paint< 2.0s2.0s
Cumulative Layout Shift< 0.050.003
Client JavaScript< 60 KB< 2 KB

None of this required a performance sprint at the end. It came from four decisions made at the start and held to on every page: static HTML, owned fonts, borders over blur, compositor-only motion. Fast wasn’t a feature we added. It was the absence of weight we never took on.

The same instinct runs through everything else here. It’s why the products all stand on one shared foundation instead of four, and why that foundation was worth building once properly rather than reassembling per project. Speed, in both senses, comes from deciding once and not relitigating it.

Frequently asked questions

Do you need a JavaScript framework to build a fast marketing site?
No. This site ships under 2KB of JavaScript and uses no client framework. Astro renders everything to static HTML at build time and only hydrates the few interactive pieces. For a marketing site, a framework on the client is usually weight you pay for and never use.
What is the single biggest Lighthouse win for a static site?
Self-hosting your fonts and preloading the ones above the fold. Third-party font CSS is render-blocking and adds a DNS lookup plus a round trip before any text can paint. Moving the files onto your own origin and preloading them was worth more than any other change we made.
How do you get CLS close to zero?
Reserve space for everything that loads late. We set explicit dimensions on the OG and product images, preload the display font so text doesn't reflow when it swaps, and animate only transform and opacity — never layout properties like width or top. That kept cumulative layout shift at 0.003.

Related posts