Built for Shopify grades your embedded app on three web vitals measured inside the admin: LCP of 2.5 seconds or less, CLS of 0.1 or less and INP of 200 ms or less, each at the 75th percentile of page loads over 28 days, with at least 100 samples per metric. App Bridge collects them, and shopify.webVitals.onReport() hands you the same numbers.
So you do not have to wait for the Dev Dashboard to find out a release made things worse. The metrics Shopify grades on reach your own code on every load, and you can store them and compute the same percentile yourself.
The thresholds come from section 2.1 of the Built for Shopify requirements. The admin performance guide covers how collection works. Everything below was checked against both as of November 2026.
How does Shopify measure web vitals for an embedded app?
Shopify records the metrics through App Bridge, in a runtime separate from your app, on every load of your app through any route. Your Lighthouse score does not come into it. The admin performance guide says plainly that Lighthouse “might not accurately capture performance” because your app runs in an iframe.
A few details from Shopify’s admin performance FAQ change how you read the numbers.
The values are calculated with Google’s web-vitals package. Tracking starts when the iframe content starts loading, so the admin’s own load time before that point is not counted against you. Slow connections are not filtered out; a Shopify staff answer in that thread says “we do not exclude any calls”. And App Bridge has to be present across the whole app, or loads on the routes without it are not recorded.
INP is the awkward one. It is only collected on the latest App Bridge, loaded from the CDN script tag. An app that bundles an old App Bridge package and never adds the script has no INP data at all, which is not a pass.
Below 100 samples in the window, a metric is not assessed at all, and a low-traffic app can sit in that gap for months.
Is App Bridge loaded on every route of your app?
If you are on the React Router app template, AppProvider from @shopify/shopify-app-react-router/react renders the script for you. In version 4.0.0 of that package it outputs <script src="https://cdn.shopify.com/shopifycloud/app-bridge.js" data-api-key={apiKey}>, plus the Polaris web components script.
The catch is where AppProvider lives. The template renders it in app/routes/app.tsx, the layout for /app/*. Any page you add outside that layout, such as a settings screen someone put at /settings or a legacy page carried over from an older app, renders without App Bridge. Those loads are invisible to Shopify, and the requirements also say App Bridge belongs on “every document of your app”. If you are still moving off Remix, our Remix to React Router 7 migration guide covers where the provider ended up.
For an app that does not use the template, the requirement is the same script tag in the <head> of each document, after <meta name="shopify-api-key" content="...">.
How do you see the numbers while developing?
App Bridge has a debug switch. Add the shopify-debug meta tag before the App Bridge script and it logs web vitals to the browser console. The Config API reference shows it first in the <head>, ahead of the API key meta tag and the script.
In the template, the root layout’s <head> renders before the AppProvider script, so app/root.tsx is the right place. Gate it to development:
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
{import.meta.env.DEV && <meta name="shopify-debug" content="web-vitals" />}
<Meta />
<Links />
</head>
You get two sets of logs. The real-time set comes from the web-vitals library and includes attribution data, which points at the slow-loading element or the routine that held things up. The send-time set shows the final values Shopify records. When they disagree with another tool, the guide gives the usual reason: other tools may measure each in-app route change, while Shopify measures page loads.
Can you collect Built for Shopify web vitals yourself?
Yes. The Web Vitals API exposes shopify.webVitals.onReport(callback). The callback receives a report with a metrics array, and each metric has id, name, value, an optional country and an optional version (the App Bridge build that produced it). Timing values are in milliseconds; CLS is unitless. Passing null unregisters the callback.
One thing from the type definitions in @shopify/app-bridge-types 0.7.3: onReport is declared optional on WebVitalsApi, so a strict TypeScript project needs a guard. This hook type-checks against that package with strict on:
// app/useWebVitals.ts
import { useEffect } from "react";
export function useWebVitals(endpoint = "/web-vitals") {
useEffect(() => {
// onReport is optional in the App Bridge types, so guard it.
const webVitals = window.shopify?.webVitals;
if (!webVitals?.onReport) return;
webVitals.onReport((report) => {
const body = JSON.stringify({
path: window.location.pathname,
metrics: report.metrics.map(({ id, name, value, country, version }) => ({
id,
name,
value,
country,
version,
})),
});
navigator.sendBeacon(endpoint, body);
});
return () => {
void webVitals.onReport?.(null);
};
}, [endpoint]);
}
Call useWebVitals() inside the App component in app/routes/app.tsx. Sending the current path is the addition that pays off: an app-wide percentile tells you that you are slow, and the path tells you where.
The receiving end is a resource route. navigator.sendBeacon() cannot set headers, so the request carries no session token. Treat it as untrusted telemetry: parse defensively, accept known metric names only, and cap how much one request can write.
// app/routes/web-vitals.ts
import type { ActionFunctionArgs } from "react-router";
const KNOWN = new Set(["LCP", "CLS", "INP", "FCP", "TTFB"]);
export async function action({ request }: ActionFunctionArgs) {
let parsed: unknown;
try {
parsed = JSON.parse(await request.text());
} catch {
return new Response(null, { status: 400 });
}
const metrics = (parsed as { metrics?: unknown }).metrics;
if (!Array.isArray(metrics)) return new Response(null, { status: 400 });
for (const m of metrics.slice(0, 20)) {
if (!KNOWN.has(m?.name) || typeof m?.value !== "number") continue;
// Swap this for your metrics store. One JSON line per sample is enough.
console.log(JSON.stringify({
at: Date.now(),
path: typeof (parsed as { path?: unknown }).path === "string" ? (parsed as { path: string }).path.slice(0, 200) : null,
name: m.name,
value: m.value,
country: typeof m.country === "string" ? m.country : null,
}));
}
return new Response(null, { status: 204 });
}
How do you compute the 75th percentile the same way?
Shopify states the percentile and the window but not how it interpolates between samples, so pick a method and keep it stable. Nearest-rank is the simplest:
// app/p75.ts
const BUDGET: Record<string, number> = { LCP: 2500, CLS: 0.1, INP: 200 };
/** Nearest-rank 75th percentile. Returns null below Shopify's 100-sample floor. */
export function p75(values: number[]): number | null {
if (values.length < 100) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.ceil(sorted.length * 0.75) - 1];
}
export function grade(samples: { name: string; value: number }[]) {
return Object.entries(BUDGET).map(([name, budget]) => {
const score = p75(samples.filter((s) => s.name === name).map((s) => s.value));
return { name, p75: score, budget, pass: score === null ? null : score <= budget };
});
}
Feed it the last 28 days of samples and you get pass, fail or null for “not enough data yet”. Run it per path as well as across the app. A single heavy report screen can drag the whole app’s p75 over 2.5 seconds while every other page is fine.
Expect your figures and Shopify’s to differ. Developers on the Shopify community forum have reported callback LCP values well under 2.5 seconds while the dashboard showed the app over it. A Shopify staff reply in that thread says both come from the same source, but the Partners Dashboard averages the scores, and the debug tools show the raw metrics. Your own data is for spotting a regression on the day you ship it; the Dev Dashboard is still the grade.
What actually moves LCP in an embedded app?
The first load of an embedded app can spend its time on authentication before anything paints. The admin performance guide’s main recommendation is token exchange, which “trades a short-lived ID token for an access token instead of redirecting the merchant”. The React Router template does this already. An app that still redirects through /admin/oauth/authorize on load makes the merchant wait for that redirect before your page can render.
After that it is ordinary front-end work. Render the page frame on the server, show loading progress instead of an empty iframe (the guide asks for clear loading progress), keep third-party widgets such as chat off the critical path (the FAQ suggests loading them on an interaction or after a delay), and reserve space for anything that arrives late so CLS stays near zero. For INP, the debug logs’ attribution points at the handler; our guide to diagnosing INP before optimising it covers what to do once you know which one.
What we would do
Add the debug meta tag today and look at one cold load of every route. Check that every route renders App Bridge. Then wire up onReport before you need it, because the 28-day window means a regression you find through the dashboard has already been counting against you for weeks. Skip the custom pipeline only if your app gets fewer than 100 loads in 28 days. Nothing is assessed at that volume, and the debug logs are enough.
We build and maintain embedded Shopify apps, including performance work aimed at Built for Shopify review. If your app is stuck just over a threshold, our Shopify development team can find out which route is responsible.