Skip to content

Engineering stories · 03

Measuring real users, not only PageSpeed

  • Core Web Vitals
  • RUM

How I built a small real-user monitoring pipeline on a Shopify product page, used it to find a layout-shift spike the lab could not see, and fixed each cause without giving up pixel accuracy.

Core Web Vitals · web-vitals attribution · Google Apps Script · Critical CSS · Font loading · Shopify OS 2.0

This is deep dive 03 from the Lumway storefront project. It owns the performance and measurement story end to end; deep dive 06 owns the wider analytics picture. Here the subject is narrow: the product page, its Core Web Vitals, and the instrument I built to trust the numbers.


Context: a phone, paid traffic, a decision made in seconds

Lumway sells supplement gummies direct to consumer in the US. Most of the buying happens on two surfaces, the product detail page and a set of advertorial landings that carry the same buy box. Traffic is mostly paid and mostly mobile. A visitor arrives from an ad, on a mid-range phone, often on a slow connection, and decides in a few seconds whether this looks like a real product worth paying for. A page that flickers, jumps, or paints a zoomed frame before settling reads as cheap at the worst possible moment. So performance here was never an abstract score to chase. It was first impression and decision speed on the surface where the money is made.

The problem: the lab and the field disagreed

I started with PageSpeed Insights and Lighthouse. The lab numbers for the theme were fine: on a fast, cached test load the product page painted quickly and held still.

Real visitors told a different story. A layout-shift spike near 0.9 hit roughly a third of mobile CLS pageviews, and it was effectively invisible on the fast test runs I was doing. A synthetic run warms the cache, sits on a strong connection, and often paints before the exact sequence that caused the shift ever occurs. The shift was real, frequent in the field, and my lab tooling kept telling me the page was healthy.

The framing matters, because it is easy to turn this into a cheap "PageSpeed lies" line, and that would be wrong. PageSpeed and Lighthouse are good diagnostics: they surface render-blocking resources, oversized images, and main-thread cost with real rigor. What they are not is a substitute for measuring the visitors you actually have. A single synthetic profile cannot represent the spread of devices, connections, and cache states real traffic brings. The lab is a controlled probe; the field is the truth about experience. I needed both, and I only had one.

Why lab-only was not enough here

Two specifics made the gap concrete. The spike was conditional on timing: it came from a resource that loaded late and reflowed the page, so on a warm, fast load the resource was already present, no reflow, no shift. The exact condition that produced the bad experience was the one the lab was least likely to reproduce. And PageSpeed was pointing me at the wrong work: its loudest flag was image bytes on the hero (more on that tradeoff later), while the layout shift actually hurting real users was not the headline. Worked top down, the report would have had me softening the product image and never touching the real defect. I needed field measurement, scoped to this page, that I owned.

Constraints on the instrument

I set a few hard limits before writing anything.

  • No new infrastructure. No analytics vendor, no server, no database. The value had to be in the data, not in a system to maintain.
  • It can never break the page. Performance code that degrades the page it measures is worse than none.
  • Out of marketing's data. The store already had GA4 and product analytics, both marketing-owned. Perf telemetry landing there would be noise in their funnel and noise for me to filter back out.
  • No PII, scoped tightly. Measure the custom product pages, nothing else, and carry nothing about the person.

The instrument: a small RUM pipeline I own

I built the whole thing as one Shopify snippet, pdp-web-vitals.liquid, about 51 lines, wrapped in a single async IIFE inside try/catch so a failure anywhere leaves the page untouched. It does four things.

It measures the real metrics. It loads the web-vitals v4 attribution build and subscribes all five listeners: LCP, CLS, INP, FCP, TTFB. Field values from the visitor's own session, not a lab estimate.

It scores each metric the way Lighthouse does. A "good" or "poor" rating is too coarse to see a page getting slowly worse, so I reimplemented Lighthouse's log-normal scoring curve (the erfc complementary error function via the Abramowitz-Stegun approximation) against the same thresholds Lighthouse uses (LCP good/poor at 2500/4000 ms, CLS at 0.1/0.25). Every metric arrives as a 0-100 score, legible on its own and trendable over time.

It names the culprit. The attribution build reports which element caused each metric, stored as metric_target, capped at 160 characters: largestShiftTarget for CLS, element for LCP, interactionTarget for INP. This one column turned the exercise from guessing into fixing. A CLS number tells you the page moved. largestShiftTarget tells you what moved.

It ships the row cheaply and safely. Transport is a single fetch to a Google Apps Script endpoint:

fetch(ENDPOINT, {
  method: 'POST', mode: 'no-cors', keepalive: true,
  headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
  body: body
});

Three choices there are deliberate. text/plain keeps the request "simple" so the browser skips the CORS preflight, which an Apps Script endpoint would not answer cleanly anyway. mode: 'no-cors' makes it fire-and-forget; I only need to deliver the row, not read a response. keepalive: true lets the send survive page unload, so a metric finalized at the end of the visit still reports. There is no client-side sampling. Volume is bounded by scope instead: the snippet renders only on the custom product pages, only when an enable_rum toggle is on. The Apps Script appends each row to a private Google Sheet.

Why a private Sheet and not GA4

GA4 was already on the store, so why stand up a Sheet.

  • Ownership. GA4 is marketing-owned and, for this, noisy. A Sheet is engineer-owned and quiet. When I want p75 per metric and the top offending elements, I read my own table, not a report in someone else's property.
  • Zero infrastructure. Apps Script plus a Sheet is nothing to run, pay for, or keep alive.
  • The right columns as first-class data. Per-metric Lighthouse score and element attribution are ordinary columns here. In a general analytics tool they would be awkward custom dimensions, if they fit at all.
  • No PII, out of the funnel. The rows carry metric name, value, score, rating, target element, template, path, a coarse device flag, and connection type. Nothing about the person, and none of it contaminating the conversion funnel the business reports on.

What it revealed

With real rows landing, the picture sharpened fast. On the bad pageviews, largestShiftTarget pointed at the same place: the product page's main section, the buy box container. Something was pushing the whole main content down after first paint, then snapping it back. That single column took me from "the page shifts sometimes on mobile" to "find what displaces the main section during load," which is solvable. It resolved into four distinct causes, all producing the same family of first-paint jump, and I shipped each as its own isolated, revertable pull request.

Fix 1: the 0.9 spike was the cart drawer painting in flow

The root cause, once attribution told me where to look, was the theme's own deferred cart-drawer CSS. To cut render-blocking bytes, the drawer stylesheets load non-blocking on these templates with media="print" swapped to all on load. The side effect: the <cart-drawer> custom element is the first element of <body>, and until its CSS arrives it is unstyled, so it paints in normal flow, roughly 560px tall, at the top of the page. It pushed everything below it down; when the deferred CSS landed and made the drawer position:fixed; visibility:hidden, the page snapped back up. That snap was the shift, and it matched what RUM recorded against the main section.

Before the async stylesheet links, I inline a tiny critical style that pins the drawer's closed state:

cart-drawer{position:fixed;top:0;right:0;left:auto;width:100vw;height:100%;visibility:hidden;z-index:1000;}
cart-drawer.active{visibility:visible;}

Specificity is the whole trick. The closed-state rule is 0-0-1, deliberately weak, so when the real stylesheet loads its .active opener (0-2-0) still wins and the drawer opens normally. The drawer is now out of flow from the first paint, so it can never displace the page, and nothing about opening it changes. Sandbox-verified, CLS on that path went from 0.75 to below 0.001 (a lab measurement on a repro, not a field percentile; the field spike that prompted it came from RUM). That set the pattern for the rest: every deferred resource paired with an inline guard that reserves its space or fixes its position.

Fix 2: the mobile zoom flicker was a misplaced viewport meta

Separately, mobile first loads rendered zoomed out, full-width, then snapped to the correct scale about 0.8s later. Structural, in the theme's <head>: the <meta viewport> tag sat after roughly 50KB of inline app scripts (a page optimizer and an A/B tool injected before it). On a slow connection the browser found no viewport instruction yet, laid the page out at its default 980px width (scale about 0.42), and painted a zoomed frame. Only after parsing tens of kilobytes of inline script did it hit the viewport meta and reflow to device width.

The fix is ordering: move charset and viewport to the very top of <head>, before any app renders. The viewport meta moved from around byte 54520 to byte 179. The browser now knows the correct width before it paints anything, so there is no zoomed frame to snap out of.

Fix 3: styles before markup, or the stars and headings jump

A third cause was a convention problem in more than one place. A section's {% style %} block used to sit after its markup, so on a slow load the browser paints the markup unstyled, then restyles when the CSS parses. Two symptoms came from this. The Figma-exported rating stars are SVGs with preserveAspectRatio="none" and width/height="100%"; their only size constraint lived in that late {% style %}, so a star briefly rendered around 412px wide, most of the viewport, for about 150ms before snapping to 20px. And the h1 first painted at the theme's default 40px with no padding, then snapped to the section's 32px with correct spacing.

The fix is a rule I made non-negotiable for every custom section on this store: {% style %} goes first, before the markup. The same defect and fix in the gallery section took a desktop gallery CLS from 0.893 to 0.004 (sandbox measurement).

Fix 4: reserve height, preload the right bytes, host the font

The last group is about not letting known-size things arrive unmeasured.

  • Reserve gallery slide height from the real image ratio. Each slide sets aspect-ratio from the image's server-side preview_image.aspect_ratio, so the slot holds the correct height before the pixels arrive. The stage image stays width:100%; height:auto with no forced ratio, so it hugs the uploaded photo with no letterboxing while the slide around it reserves the space.
  • Preload the LCP gallery image, byte-matched. The first gallery image is the LCP element, so pdp-lcp-preload.liquid preloads it in <head> with imagesrcset and imagesizes identical to the gallery <img> (widths 343/494/658/988/1316). Byte-matching matters: if the preload and the real <img> disagree on candidates, the browser downloads the LCP image twice, slower than not preloading at all.
  • Preload the self-hosted Inter Tight woff2. The product page declares an @font-face for Inter Tight from a self-hosted file, otherwise discovered only when that CSS parses, seconds late on mobile. Preloading it in <head> (with crossorigin, which font fetches require even same-origin) moved the request from roughly 1965ms to 312ms.

The tradeoff: pixel-perfect over marginal bytes

Two decisions on this pass went against what the lab wanted, on purpose.

Fonts. I loaded Oswald and League Spartan non-blocking with display=optional. That kills swap-driven CLS: the browser either has the font in time or uses the fallback and never swaps, so those two faces can never cause a late reflow. I deliberately did not do the same to Inter Tight, the primary brand face. display=optional would show a system fallback on the first uncached load, breaking the pixel-perfect first impression. So Inter Tight is preloaded and self-hosted instead of downgraded: accept a small font fetch to keep the intended type on first paint.

The hero image. PageSpeed's loudest flag was that "-72.6 KiB" on the hero, and I did not cut it. Downsizing the LCP image below 2x DPR clears the flag and visibly softens the product on the DPR 2.6 to 3 phones most of the paid mobile traffic uses. On a page where the product photo is the pitch, a soft hero costs more than a few dozen kilobytes saved. Pixel-perfect beat marginal bytes, knowing it leaves a yellow flag in the report, and I would make the call again.

Honest scope: the scary TBT is not the theme

I want to be straight about what I did not fix, because it matters for judging the work. The desktop Total Blocking Time looks alarming, around 2920ms. That is almost entirely third-party application JavaScript, not the theme: an on-site personalization app around 510KB, a tag manager around 473KB, a session-recording tool around 147KB, product analytics around 201KB, and email software around 98KB. The theme's own numbers are already healthy: FCP around 0.6s, LCP roughly 1.1 to 1.6s on desktop, Speed Index around 1.5 to 1.7s (lab measurements).

Reducing that TBT means removing or deferring apps, and each earns its place for a business reason (personalization, tag governance, session replay, analytics, lifecycle email). That is a business decision about cost and benefit, not a theme change I get to make unilaterally. So I measured it, attributed it, reported it, and left the tradeoff with the people who own it. Claiming I "fixed TBT" by deleting someone's analytics would be dishonest and out of scope.

Validation: clean-room harnesses, not vibes

Every fix was verified in a controlled repro before it shipped. I built small Puppeteer harnesses driving Chrome over the DevTools Protocol under slow-4G throttling:

  • A star harness that reads the SVG's bounding box during load, so the giant-star flash is caught as a measured width, not an impression.
  • A font harness that reads network timing for the woff2 request, confirming the preload moved it earlier.
  • A metrics and burst pair that uses the CDP scale timeline and paired screenshots to catch the mobile zoom flicker as a scale change over time.

When the PageSpeed anonymous quota ran out mid-pass, I ran lighthouse@12 locally. The discipline throughout: reproduce the defect under throttling, measure it, apply the fix, measure again, and only then ship, each change isolated so it reverts in one click.

Outcome

Separated by how strongly I can defend each.

Measured (lab and sandbox). The drawer path CLS went from 0.75 to below 0.001. The desktop gallery CLS went from 0.893 to 0.004. The mobile zoom flicker, the giant-star flash, and the heading jump were eliminated by ordering the viewport meta and section styles correctly. The Inter Tight request moved from about 1965ms to 312ms. These are lab and sandbox repros under throttling, labeled as such, not field percentiles.

Field-derived. The defect that started all of this, the roughly 0.9 CLS spike affecting about a third of mobile CLS pageviews, came from the RUM attribution data, not a lab guess. That is the number I trust most, because it came from real sessions.

Verified. A working RUM pipeline now reports LCP, CLS, INP, FCP, and TTFB with per-element attribution and Lighthouse-curve scores, on the custom product pages, into a private Sheet the team owns.

The store's conversion and revenue figures belong to the brand and sit under NDA. And I would not pin a specific revenue number to a single CLS fix even if I published one: the honest unit here is the engineering.

Customer impact, honestly framed

These fixes protect trust and decision speed. A mobile visitor from a paid ad now gets a page that holds still, paints at the right scale, and shows the product sharp on a high-DPR screen: no zoom-and-snap, no giant star flashing under the gallery, no content lurching as the cart drawer settles. On a surface where the whole pitch lands in a few seconds, that is friction removed from the buying decision. Protection and enablement at the point of decision, with the commercial figures kept to the brand under NDA.

Lessons

  • Synthetic tools are diagnostics, not a verdict on experience. Useful for finding render-blocking resources and heavy main-thread work, and no stand-in for measuring the visitors you have. Use both, and when they disagree, the field wins.
  • Attribution is what makes RUM pay off. A CLS number is a smoke alarm; largestShiftTarget is the room number. The element column turned a hunt into a set of fixes.
  • Defer bytes, but never let an element reflow. Every async or deferred resource needs an inline critical guard that reserves its space or fixes its position. And styles go before markup, always, or late CSS becomes FOUC and CLS.
  • Right-size the instrument, and separate theme cost from third-party cost. The measurement system was one 51-line snippet, an Apps Script, and a Sheet. Most of the scary blocking time was not mine to fix, and saying so plainly is part of the job.

Technical references

Primary files, for anyone reading the theme:

  • snippets/pdp-web-vitals.liquid: the RUM instrument (web-vitals attribution build, Lighthouse-curve scoring, element attribution, no-cors transport to Apps Script).
  • layout/theme.liquid: head ordering (charset and viewport first, before app scripts) and the inline critical cart-drawer closed-state style that killed the 0.9 spike.
  • snippets/conditional-assets.liquid: the non-blocking cart-drawer CSS load that the inline guard makes safe.
  • snippets/pdp-lcp-preload.liquid: the LCP image preload (byte-matched to the gallery <img>) and the Inter Tight woff2 preload.
  • snippets/product-gallery-lum.liquid: slide height reserved from the real image aspect ratio, and the LCP <img> the preload must match.
  • snippets/oswald-font.liquid, snippets/league-spartan-font.liquid: non-blocking secondary fonts with display=optional.
  • sections/lum-template-font.liquid: the enable_rum gate that scopes the RUM snippet to the custom product pages.

RUM is owned in this deep dive; deep dive 06 references it as one of four isolated analytics pipelines. The cart drawer's behavior under a third-party app is covered in deep dive 02; here it appears only as the source of the layout shift.

Back to the caseLumway

Need a Shopify engineer who can work across the full commerce path?

I work where storefront UX, product logic, platform constraints, measurement, and production delivery meet.

Start a conversation