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
- HTTP 429 Is Not a Traffic Problem: How a Rate Limit Silently Broke Our Search Traffic
- OG Tag Previewer Fields Guide: Five Inputs, Two Limits, One Silent Failure
- URL Encoding Percent Guide: Both JavaScript Functions, Verified Side by Side
More articles in Web Development