[CODE]

Chasing LCP and CLS on a Static Astro Intro Page

Lighthouse Mobile at 76 on an Astro landing page running Google Ads. The hero image downloaded in 274ms. The browser still couldn't paint for another 1,756ms. Three root causes — render-blocking CSS, a preload scanner blind spot, Google Fonts overhead — and none were images.

8 min read AI-generated
astro performance core-web-vitals cloudflare css

The hero image downloaded in 274ms. The browser still couldn’t paint for another 1,756ms.

Lighthouse Mobile gave the intro page a score of 76. The quote had committed to ≥ 90. The gap wasn’t optional.

Why the score is infrastructure, not metrics theater

This intro page is the destination URL for Google Ads campaigns running on behalf of a B&B in Hengchun. The site’s entire purpose is to convert paid traffic into direct bookings — LINE messages and phone calls that bypass OTAs, whose commission rates run far higher than most people outside hospitality assume. Each direct booking is structurally more profitable than the equivalent OTA booking, but only if the ad campaign runs at defensible cost.

Google Ads Quality Score incorporates landing page experience. Landing page experience incorporates Core Web Vitals. A Lighthouse Mobile score of 76 on the exact URL the campaign points to means elevated CPC on every click from day one, compounding across an entire booking season. Getting past the “good” thresholds isn’t polish — it’s the prerequisite for the campaign math to work.

Root cause 1: the CSS that blocked paint after the image arrived

Astro’s default build.inlineStylesheets: 'auto' inlines stylesheets under roughly 4KB into the HTML. The site’s shared bundle was 12KB — enough to cross the threshold — so Astro emitted a separate /_astro/index.*.css file linked from <head>. The browser had to request and parse that file before it could finalize the render tree. The hero image was already decoded in memory. The browser couldn’t use it.

Once the preload was in place (see root cause 2), this became measurable in isolation: even with the hero AVIF fetching in 274ms, “element render delay” — the gap between resource arrival and actual paint — measured 1,756ms. The browser was waiting for CSS.

The fix was a single line in astro.config.mjs:

build: {
  inlineStylesheets: 'always',
}

Every stylesheet is now emitted as an inline <style> tag, arriving in the same response as the HTML. No separate round trip. Element render delay dropped to under 50ms.

The cost: each page’s HTML gains roughly 12KB of CSS that can no longer be cached separately and reused across page loads. For a 48-page static site, that’s about 576KB of additional dist size. Acceptable — Cloudflare compresses the HTML at the edge (gzip reduces it roughly 70%), and the 3-4KB wire-delta per page trades cleanly against recovering 1,700ms of render delay.

Root cause 2: the preload scanner can’t read <picture><source>

The browser’s preload scanner is a secondary parser that runs parallel to HTML download, looking ahead in the byte stream for resources to fetch early. It can find <img src> and <link rel="preload"> natively. It cannot follow the source selection logic of a <picture> element: it doesn’t know which <source type> the browser will choose, so it skips the whole block.

The hero was wrapped in a <picture> element with an AVIF <source> and a WebP <img> fallback — the correct responsive multi-format pattern. The preload scanner skipped it. The AVIF wasn’t discovered until the HTML parser reached the <picture> element, well into the critical path. For a page where the hero is the LCP element, late image discovery translates directly into LCP delay.

The fix was an explicit responsive AVIF <link rel="preload"> emitted in <head> by the layout component, driven by a heroPreload prop:

<link
  rel="preload"
  as="image"
  type="image/avif"
  imagesrcset="/images/home/hero-coast-480.avif 480w,
               /images/home/hero-coast-960.avif 960w,
               /images/home/hero-coast-1600.avif 1600w,
               /images/home/hero-coast-2400.avif 2400w"
  imagesizes="100vw"
  fetchpriority="high"
/>

The imagesrcset attribute lets the preload scanner select the right size for the current viewport during HTML parsing. When the browser later evaluates the <picture> element’s source selection, the correct AVIF is already in cache.

The preload and the <picture> element must stay in sync — the imagesrcset widths hardcoded in the layout component match exactly the variants ResponsivePicture emits. A comment enforces this coupling.

Root cause 3: Google Fonts cost 250ms that the content didn’t justify

Cormorant Garamond and Nunito required four <link> tags in <head>: two preconnect hints, one preload for the font CSS, one stylesheet. Lighthouse measured the combined overhead at roughly 250ms on mobile even with font-display: swap — the font CSS itself needs a round trip before the browser knows which font files to request, adding latency before the display font can render.

The site is primarily CJK. Cormorant’s distinctive italic renders on a handful of Latin headlines across the entire site. The trade-off between 250ms and a system serif stack didn’t survive scrutiny. Both typefaces were dropped in favor of system fonts: Iowan Old Style → Cambria → Georgia → serif for display, -apple-systemBlinkMacSystemFontSegoe UIsystem-ui for body. The visual cost is real and localized — a careful eye on a desktop will notice Cormorant is gone — but it doesn’t touch the reading experience for the primary audience.

Hero AVIF compression: 137KB was too conservative for the LCP slot

After fixing CSS and preload, the 960w AVIF variant — the file phones download on this site, where a 412px viewport at DPR 1.75 resolves to roughly 720px rendered width and the 960w source wins the srcset negotiation — was still 137KB. On Slow 4G (1.6Mbps), that’s roughly 685ms of transfer time for the LCP element.

Re-encoding at quality 38, down from 60, brought it to 57KB — 58% smaller, roughly 343ms faster on the same connection. The trade-off: faint banding in the turquoise gradient at 100% zoom on a calibrated desktop monitor. At 412px on a phone, it’s below the perceptual threshold. The owner reviewed both versions and accepted the change.

The other images on the site were not touched. Compression changes that aren’t visible are wasted effort; compression changes that are visible but in the wrong place (the LCP hero, seen at full viewport width on every visit) are not.

Cloudflare cache: cold starts were slower than no CDN

Cloudflare was placed in front of Railway during troubleshooting unrelated to performance — a Facebook Open Graph scraper issue that turned out to be a domain reputation flag affecting new domains registered after Meta’s April 2026 policy change. It didn’t resolve that issue, but it stayed because it could help.

Without cache rules, it made things worse. Railway’s default Cache-Control: max-age=14400 tells Cloudflare to revalidate every 4 hours. Cold-cache requests route from the edge to the Railway origin, adding a hop that isn’t there on Railway direct. Lighthouse found the hero AVIF’s TTFB at 2,028ms through Cloudflare cold; Railway direct was 438ms.

Two cache rules fixed this:

  • /images/*: Edge TTL 30 days
  • /_astro/*: Edge TTL and Browser TTL both 1 year (Astro emits hash-named bundles; a content change means a new URL, so permanent cache is safe)

Hero AVIF TTFB: 2,028ms → 180ms. An 11× improvement on cold starts, with warm-cache serving being effectively instant.

CLS: the viewport-width assumption in a masonry grid

A concurrent audit of the gallery page on a sister property found CLS of 0.6. The cause was a JavaScript masonry implementation using grid-auto-rows: 10px with cell spans computed from getBoundingClientRect() at runtime. The span calculation depends on viewport width. Lighthouse mobile uses a 412px viewport; the implementation was developed and validated at 360px. At 412px, every image calculated a span 2 units larger than at 360px, and corrected itself after initial render — every image produced a layout shift.

The fix replaced the masonry entirely with aspect-ratio: 3/4 on each cell and object-fit: cover on the image. No JavaScript required. CLS dropped from 0.6 to 0.001.

The masonry had also been causing a second shift: a security script that wrapped images in protected containers after paint was adding its own DOM mutations. Switching to a CSS-only grid and pre-applying the container class at render time eliminated both sources simultaneously.

Results

BeforeAfter
Lighthouse Mobile Performance7694
Mobile FCP2.9s1.1s
Mobile LCP4.6s2.0–3.2s
Element render delay1,756ms< 50ms
Desktop Performance9498

The biggest gain came from a config flag that forces Astro to inline CSS. Not image format selection. Not CDN configuration. Not JavaScript reduction. The bottleneck was a 12KB stylesheet that crossed an arbitrary size threshold, triggering a round trip that blocked paint long after the hero image had already arrived.

The next competitor’s mobile score was 64. Getting to 94 isn’t primarily about beating that number — it’s about removing the Quality Score ceiling that was taxing every click the campaign generated.