Measured
Headless Shopify · ~40,000 products · React 17

Same product page, same code: field LCP between 1.0 and 6.4 seconds.

This engagement is covered by a confidentiality obligation, so what follows is the mechanism with the client removed. The mechanism is the part worth copying anyway — the bug was in what the browser was timing, not in how heavy the page was.

1.0–6.4s

The spread was the defect. The average was fine.

Largest Contentful Paint on product pages moved between 1.0 and 6.4 seconds from one load to the next. Depending on the session, the same template on the same code landed in all three of Google's bands:

good needs improvement poor

The mean was never the problem. The variance was — and the 75th percentile Google assesses is drawn from the slow end of that spread, not the middle of it.

How this was seen. Field data from real sessions, read at the 75th percentile. A single synthetic Lighthouse load cannot report a distribution; it reports one load on a clean machine. The field data showed the variance. The lab data did not, which is why the page had looked healthy every time anyone checked it by hand.

Two compounding causes

The framework was discarding the element being measured

Product pages were rendered as Liquid server-side HTML, and the React app replaced that HTML when it mounted. ReactDOM.render() was throwing away the server-rendered hero image and re-mounting it. Largest Contentful Paint was therefore being measured from the remount, not from the original server-rendered paint.

This is why the usual levers did nothing. Compressing the image, resizing it, changing its format, tuning the CDN — none of it could move a measurement that was not timing the server-rendered image in the first place.

Roughly forty blocking scripts decided when the remount happened

The <head> carried around forty blocking scripts, which made React's boot time non-deterministic. The remount therefore landed at a different moment on every load, and that is where the run-to-run spread came from. The remount is what broke the measurement; the blocking scripts are what made it move around.

The fix

Two changes, in that order. First, make the server-rendered placeholder paint at First Contentful Paint without requiring JavaScript: CSS had been hiding it, so it never painted before React booted and the server-rendered HTML was doing no work for anybody. Second, stop the client from destroying it on mount, so the paint the browser has already made is the paint that gets measured.

Neither change touched image weight, the CDN, or the third-party scripts. Those scripts are still a real cost and still worth reducing. They were not the reason the number was wrong.

The outcome

Field Largest Contentful Paint became consistently 1.1 seconds. Not a better average — a stable number, which is the thing the 75th percentile actually rewards.

0s 2.5s 4.0s 6.5s
good, under 2.5s needs improvement poor, over 4.0s
Before — 1.0s to 6.4s, unpredictable After — consistently 1.1s
Google's thresholds for Largest Contentful Paint. The problem was never the mean — it was the variance, which is exactly what a single synthetic run cannot show you.

What this generalises to

On any storefront where a client-side framework hydrates over server-rendered HTML, check whether the framework replaces the LCP element rather than adopting it. That one question is the transferable part of this engagement, and it applies well beyond React and well beyond Liquid.

Three signs you are looking at this bug:

  • Field LCP is much worse than lab LCP on the same template.
  • The number has high run-to-run variance, rather than being consistently bad.
  • Image work — compression, format, sizing, preloading — changes nothing.

A Lighthouse score cannot show you this, because a score is one load and the problem is a distribution. Image optimisation cannot fix it, because the image is not what is being timed.

Limits. One engagement, one codebase. The mechanism generalises; the magnitude does not. How much time this returns on a given storefront depends on what its LCP element is, how much blocking script sits in front of hydration, and whether its framework is discarding that element at all — which is a question to answer with field data, not with an assumption.

Does your storefront hydrate over server-rendered HTML?

Then it is worth checking which element the browser is actually timing. If your field LCP is worse than your Lighthouse score and the number moves around between runs, this is the first thing I would look at.

Ask about your field data