Web Development

LCP in a React SPA: Why Preloading the Image Fixed Nothing

Our largest contentful paint was a 15 KB image. It loaded from the CDN edge in under 300 ms, cached, compressed, correctly sized. Lighthouse still reported 3.7 seconds. If you run a single-page application, the LCP number measures your JavaScript boot time, not your image delivery, and optimizing th

By WebRecast Published

Our largest contentful paint was a 15 KB image. It loaded from the CDN edge

in under 300 ms, cached, compressed, correctly sized. Lighthouse still

reported 3.7 seconds. If you run a single-page application, the LCP number

measures your JavaScript boot time, not your image delivery, and optimizing

the image is a dead end.

This is the diagnosis, the fix that worked, and the measurement traps that

made the problem look like three different problems.

Why the image was not the problem

The page is a React SPA. The largest element, a hero banner, exists only

after this chain completes:

1. The HTML document arrives.

2. The browser downloads and parses roughly 800 KB of JavaScript across the

entry chunk and five vendor chunks.

3. React mounts and renders the page component.

4. The hero `<img>` element enters the DOM, and only then does its network

request start.

Steps 2 and 3 cost about 2.2 seconds of main-thread bootup on a throttled

mobile profile. The image request could not even begin until they finished.

Preloading the image file shaves milliseconds off step 4's transfer, which

was never the bottleneck.

The fix that worked: paint the element in the static HTML

We already emitted static HTML shells for crawlers, with the page's text

content rendered server-side into the root div. The fix was to move the

hero image into that same static shell:

1. Embed the `<img>` with explicit width and height, fetchpriority set to

high, inside the static body that ships with the document.

2. Preload the same image URL in the head so the request races the HTML

parse instead of the hydration.

3. Let React replace the shell content when it mounts, which it does

anyway. LCP records the first paint of the largest element, and that

paint now happens before JavaScript arrives.

One measurement after the change confirmed the mechanism: Lighthouse's LCP

element was the static shell image, not the React-rendered one. Same visual

page, 2.2 seconds of bootup removed from the critical path.

Two smaller changes shipped with it:

  • framer-motion sat on the critical path through one eagerly imported

widget. Lazy-loading three components took a 129 KB chunk out of the

initial modulepreload set.

  • The Inter variable font moved from Google Fonts to self-hosted subsets

with latin and latin-ext preloaded. That removes two preconnects and a

render-blocking CSS round trip to a third-party origin.

The CLS half of the story

Field data showed a 0.111 layout shift attributed to the footer. The cause

was structural: the static text shell and the React page have different

heights, and the swap when hydration completes moves everything below it.

After the shell and the React page rendered matching hero markup, the lab

CLS dropped to 0.053, inside the good band. The remaining risk is the shell

swap itself on slow devices, and the honest fix for that is server-side

rendering, which we evaluated and rejected for this site as out of

proportion to the traffic.

Three measurement traps we hit

1. CDN field analytics polluted by crawler rendering. Googlebot executes

JavaScript and fires analytics beacons. Our visits metric jumped 447

percent after we resubmitted sitemaps, and those slow headless sessions

dragged the Core Web Vitals distribution down. Check your RUM spike

against your crawl activity before trusting a regression.

2. PSI API quota is shared and anonymous. Our quota was exhausted on the

day we needed a before-and-after, so we ran Lighthouse locally against

the live URL instead. Own your measurement loop.

3. CrUX requires a traffic threshold. A low-traffic site gets no CrUX data

at all, so "check the field data" is empty advice until traffic exists.

Lab numbers plus honest RUM filtering is the workable substitute.

Checklist for SPA LCP

  • Identify the LCP element, then ask when its DOM node first exists, not

when its file loads. If the answer is "after hydration", the fix is in

the shell, not the CDN.

  • Embed the element in the server-delivered HTML with explicit dimensions.
  • Preload the resource and set fetchpriority high on the element itself.
  • Audit the entry chunk's modulepreload list for libraries a lazy import

could remove.

  • Validate with a lab run and read the LCP element from the report to

confirm the static version is the one being measured.

What we did not fix, on purpose

Total blocking time is still 3,650 ms. The remaining cost is the

application bundle itself and the analytics loader. GTM already defers to

first interaction. Splitting the 386 KB entry chunk is possible but the

navigation needs the slug maps it carries, so the honest answer is that the

next meaningful step is architecture, not tuning.

---

Measure your own LCP element before and after any SPA change, and the

fastest way is a Lighthouse run you control. If you want the hero image

treated this way in your own build, our performance notes live beside the

tools: https://webrecast.com/en/guides

Related guides

More articles in Web Development