← Shopify App BuilderCONTENT HISTORY

Update to Shopify App Builder

Snapshot Sep 30, 2026 · 23:13 UTC · version 1.4.1

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Use when optimizing Shopify embedded app performance — LCP, INP, CLS, TTI, cold-start, App Bridge init, Polaris bundle slimming, large GraphQL costs, Remix defer/streaming/prefetch, image lazy-loading, server cache + CDN, Prisma connection pooling, webhook handler latency, and meeting Built for Shopify performance gates. Triggers: 'app slow', 'embedded app performance', 'LCP shopify app', 'INP shopify', 'app bundle too big', 'polaris bundle slim', 'remix defer', 'prefetch intent', 'shopify app lighthouse', 'shopify performance budget', 'built for shopify performance', 'image lazy load shopify', 'graphql query cost', 'n+1 prisma'.",
  "included_files": [],
  "name": "app-performance",
  "skill_md_contents": "---\nname: app-performance\ndescription: \"Use when optimizing Shopify embedded app performance — LCP, INP, CLS, TTI, cold-start, App Bridge init, Polaris bundle slimming, large GraphQL costs, Remix defer/streaming/prefetch, image lazy-loading, server cache + CDN, Prisma connection pooling, webhook handler latency, and meeting Built for Shopify performance gates. Triggers: 'app slow', 'embedded app performance', 'LCP shopify app', 'INP shopify', 'app bundle too big', 'polaris bundle slim', 'remix defer', 'prefetch intent', 'shopify app lighthouse', 'shopify performance budget', 'built for shopify performance', 'image lazy load shopify', 'graphql query cost', 'n+1 prisma'.\"\n---\n\n# Shopify Embedded App Performance\n\nFix slow Shopify embedded apps before Built for Shopify rejects them, merchants uninstall, and Shopify demotes you in App Store search. The Web Vitals are the gate. Everything below is how you walk through it.\n\n---\n\n## 1. When to use this skill\n\nTrigger this skill when:\n\n- `shopify app dev` runs but the embedded iframe takes 4+ seconds to render\n- BFS review comes back \"fails performance\" with no specifics\n- Lighthouse on your admin route scores below 70\n- Web Vitals API reports LCP > 2.5s, INP > 200ms, or CLS > 0.1 over 28 days\n- Your bundle is > 500KB gzipped and you don't know why\n- GraphQL queries are returning `THROTTLED` (as 200 OK, not 429)\n- Prisma queries take 800ms+ in production but 40ms locally\n- Webhook handlers are taking > 2 seconds and triggering Shopify retries\n- A merchant DMs you \"your app is the slowest thing in my admin\"\n- You're about to submit for Built for Shopify and want to pass on the first try\n\nDon't use this skill for storefront / theme performance — that's a different problem (theme app extensions, web pixels, checkout extensions). This skill is about the admin embedded surface and the server behind it.\n\n---\n\n## 2. Built for Shopify performance thresholds (current)\n\nThese are the exact numbers. Memorize them.\n\n### Core Web Vitals (admin, measured via App Bridge Web Vitals API)\n\n| Metric | Threshold | Measurement window | Min sample size |\n|--------|-----------|--------------------|-----------------|\n| **LCP** (Largest Contentful Paint) | ≤ 2.5s (p75) | 28 days | 100 calls |\n| **INP** (Interaction to Next Paint) | ≤ 200ms (p75) | 28 days | 100 calls |\n| **CLS** (Cumulative Layout Shift) | ≤ 0.1 (p75) | 28 days | 100 calls |\n| **TTI** (Time to Interactive) | Aim ≤ 3.8s | Internal target | — |\n\np75 means 75% of measured launches must be at or under the threshold. One slow merchant doesn't sink you — the long tail does.\n\n### Server / API performance\n\n| Metric | Threshold |\n|--------|-----------|\n| **API p95 latency** | < 500ms |\n| **API failure rate** | < 0.1% |\n| **Min requests** (28 days) | 1000 (for checkout-adjacent apps) |\n| **Webhook ACK** | < 5s before Shopify retries (target < 2s) |\n\n### Storefront-side requirements (if you ship Theme App Extensions)\n\n- Your app must not reduce the storefront Lighthouse performance score by more than **10 points**\n- Per-surface storefront JS budget: **< 50KB gzipped** is the rule of thumb most pass on\n\n### Iframe gotcha\n\nApps rendered in the Shopify admin run inside iframes. Lighthouse run against your raw URL is misleading — it doesn't model the App Bridge bootstrap, the parent admin chrome, or the cross-origin handshake. The only metric that matters for BFS is what the **Web Vitals API inside App Bridge** reports. Wire that up first or you're flying blind.\n\n---\n\n## 3. Where embedded app slowdowns come from\n\nIn order of how often they're the culprit:\n\n### 3.1 Server cold start (40% of slow first-loads)\n\nYour Remix server is on a serverless / scale-to-zero runtime (Fly Machines stopped, Vercel Lambda cold, Render free tier). First request from a merchant pays a 2-5 second cold-boot tax. Every metric is now blown.\n\n### 3.2 App Bridge initialization\n\nThe App Bridge CDN script must load and the parent admin must complete the postMessage handshake before your iframe is \"interactive.\" If you load App Bridge late (after your bundle), INP measurements include the gap.\n\n### 3.3 Polaris bundle bloat\n\n`import { Card } from '@shopify/polaris'` from Next.js or a tree-shake-confused bundler pulls in 800KB+ of unused components. Webpack/esbuild/Vite all have known cases where Polaris's barrel exports defeat tree-shaking.\n\n### 3.4 Large or unbatched GraphQL fetches\n\nLoader fires three GraphQL queries serially because you `await`'d them. Each is 200-400ms. You've blocked LCP by 800ms before render starts.\n\n### 3.5 Prisma N+1\n\nLoop over products, `prisma.metafield.findMany({ where: { productId }})` inside. 100 products × 30ms = 3 seconds. Server p95 dies.\n\n### 3.6 Blocking webhook handlers\n\nWebhook handler does HMAC verify, writes to DB, calls 2 third-party APIs, returns 200 after 7 seconds. Shopify already retried. Now you process the duplicate. Now you're throttled.\n\n### 3.7 Unbounded images\n\n`<img src={huge.png}>` with no `width`/`height`, no lazy loading, no Shopify Image CDN transform. CLS jumps the moment images load.\n\n### 3.8 No HTTP caching\n\nEvery navigation refetches the same shop settings, the same plan info, the same merchant profile. Cache-Control header is missing, so the browser never reuses anything.\n\n---\n\n## 4. Server cold-start fix\n\n### 4.1 Runtime choice\n\n| Runtime | Cold start | When to pick |\n|---------|------------|--------------|\n| **Node on always-on VM** (Fly Machines `min_machines_running = 1`, Render Standard, Railway) | 0ms | Default. Cheapest path to consistent p95. |\n| **Bun on always-on VM** | 0ms | Pick if you measured 30%+ faster on your specific Prisma/GraphQL workload. Not a magic bullet. |\n| **Edge runtime** (Vercel Edge, Cloudflare Workers) | < 50ms | Pick only if you can live without Node-API libs (Prisma needs Hyperdrive/D1 driver). Great for read-heavy. |\n| **Serverless cold-scale** (Vercel Lambda, AWS Lambda) | 1-5s | Avoid for embedded apps unless you have warm-ping infra. |\n\n### 4.2 Warm pings\n\nIf you must run on scale-to-zero, hit your own healthcheck every 4 minutes. Cheap:\n\n```ts\n// pages/api/health.ts or app/routes/health.tsx\nexport const loader = () => new Response(\"ok\", { status: 200 });\n```\n\nThen a cron (Vercel Cron, GitHub Actions schedule, UptimeRobot free tier) curls it every 4 minutes during expected business hours.\n\n### 4.3 Platform specifics\n\n- **Fly.io:** `min_machines_running = 1`, `auto_stop_machines = false` in `fly.toml`. The $4/mo for the tiniest always-on machine is cheaper than every losing 28-day BFS window.\n- **Vercel:** prefer Pro and set `regions: ['iad1']` (or wherever Shopify's main traffic lands). Use Fluid Compute, set `maxDuration` low. Enable Edge Caching on loaders that return cacheable data.\n- **Render:** Standard plan or higher. The Free tier sleeps. Don't ship to merchants on Free.\n- **Heroku / Railway:** keep at least 1 worker dyno running; eco/hobby dynos sleep.\n- **Shopify Oxygen (storefront):** not for embedded admin apps. Don't confuse the two.\n\n---\n\n## 5. Remix optimizations\n\n### 5.1 `defer` and `Await` for non-critical data\n\nStop blocking the response on slow data. Stream it.\n\n```tsx\n// Bad — loader awaits every query, response blocks until all resolve\nexport async function loader({ request }) {\n  const { admin } = await authenticate.admin(request);\n  const shop = await admin.graphql(SHOP_QUERY).then(r => r.json());\n  const orders = await admin.graphql(ORDERS_QUERY).then(r => r.json()); // slow\n  const products = await admin.graphql(PRODUCTS_QUERY).then(r => r.json());\n  return json({ shop, orders, products });\n}\n\n// Good — only await what's needed for first paint, defer the rest\nexport async function loader({ request }) {\n  const { admin } = await authenticate.admin(request);\n  const shop = await admin.graphql(SHOP_QUERY).then(r => r.json()); // fast, needed for header\n  const orders = admin.graphql(ORDERS_QUERY).then(r => r.json());   // slow, not blocking\n  const products = admin.graphql(PRODUCTS_QUERY).then(r => r.json());\n  return defer({ shop, orders, products });\n}\n```\n\nIn the component:\n\n```tsx\n<Suspense fallback={<Skeleton />}>\n  <Await resolve={data.orders}>\n    {(orders) => <OrdersTable orders={orders} />}\n  </Await>\n</Suspense>\n```\n\nLCP fires on the header that the first `await` produced. The orders table streams in after.\n\n### 5.2 `prefetch=\"intent\"` on every internal Link\n\n```tsx\nimport { Link } from \"@remix-run/react\";\n\n<Link to=\"/app/orders\" prefetch=\"intent\">Orders</Link>\n```\n\nWhen the merchant hovers (~500ms before they actually click), Remix fetches the route's JS, CSS, and loader data. By the time the click lands, the next page is already in cache. INP measurements drop because the click doesn't trigger a network round-trip.\n\nDon't use `prefetch=\"render\"` for everything — it floods the network on every page load. Use it only for the one obvious next step (e.g., a wizard's next button).\n\n### 5.3 Route prefetching strategy\n\n- `prefetch=\"intent\"` — default for nav links and CTAs (98% of links)\n- `prefetch=\"render\"` — single \"obvious next step\" per page\n- `prefetch=\"viewport\"` — links far down a long list, prefetch when scrolled into view\n- `prefetch=\"none\"` — anything that points to a route the user will rarely click, or stale-sensitive data\n\n### 5.4 Resource routes for data the client polls\n\nIf a chart polls every 30 seconds, don't re-render the whole route. Use a resource route (`/app/api/chart-data`) that returns JSON, fetch it from the client. Resource routes skip the React component tree and just run the loader.\n\n### 5.5 Move heavy work to `clientLoader`\n\nFor non-PII data that doesn't need server context (e.g., a chart fed by a public API), use `clientLoader` so the work happens in the browser and the server loader returns instantly.\n\n### 5.6 Cache loader responses\n\nSet `Cache-Control` headers on loaders that return stable data:\n\n```tsx\nexport function headers() {\n  return {\n    \"Cache-Control\": \"private, max-age=60, stale-while-revalidate=600\",\n  };\n}\n```\n\n`private` because session-token-authed responses shouldn't be cached on shared CDNs. `stale-while-revalidate` lets the browser serve stale content while it refreshes in the background — near-zero perceived latency on the second navigation.\n\n---\n\n## 6. Polaris bundle slimming\n\nPolaris is the biggest single bundle contributor in most embedded apps. Without care, it ships 800KB+ of gzipped JS.\n\n### 6.1 Use deep imports when your bundler fails to tree-shake\n\n```ts\n// Bad — barrel import. Many bundlers pull the whole package.\nimport { Card, Button, Text } from \"@shopify/polaris\";\n\n// Good — direct import. Forces tree-shake friendliness.\nimport Card from \"@shopify/polaris/build/esm/components/Card\";\nimport Button from \"@shopify/polaris/build/esm/components/Button\";\nimport Text from \"@shopify/polaris/build/esm/components/Text\";\n```\n\nYes, it's ugly. It saves 200-400KB on Next.js 14/15 + React 18 where tree-shaking is known to fail. Check `npm run build` bundle stats before and after — if your bundler tree-shakes fine, keep the readable imports.\n\n### 6.2 Lazy-load heavy components\n\nHeavy components: `IndexTable`, `DataTable`, `ResourceList` with many rows, `Filters`, anything chart-related, `Modal` (sometimes), the entire Polaris Icons set.\n\n```tsx\nimport { lazy, Suspense } from \"react\";\nconst HeavyTable = lazy(() => import(\"./HeavyTable\"));\n\n<Suspense fallback={<Skeleton />}>\n  <HeavyTable rows={rows} />\n</Suspense>\n```\n\n### 6.3 Tree-shake icons\n\n`@shopify/polaris-icons` is huge if imported as a barrel.\n\n```ts\n// Bad — pulls every icon\nimport * as Icons from \"@shopify/polaris-icons\";\n\n// Good\nimport { CheckIcon, AlertIcon } from \"@shopify/polaris-icons\";\n```\n\nOr one level deeper if your bundler still misbehaves:\n\n```ts\nimport CheckIcon from \"@shopify/polaris-icons/dist/svg/CheckIcon\";\n```\n\n### 6.4 CSS — exactly once at app root\n\nPolaris CSS must be loaded exactly once, at the root. If you tree-shake aggressively, your bundler may drop the CSS-only side-effect.\n\n```ts\n// In root.tsx or _app.tsx\nimport \"@shopify/polaris/build/esm/styles.css\";\n```\n\nAdd `@shopify/polaris/build/esm/styles.css` to `sideEffects` in `package.json` if your bundler is over-eager.\n\n### 6.5 Frame and AppProvider — render once, never inside loops\n\n`<AppProvider>` and `<Frame>` are expensive on mount. Mount them in the root layout, never re-create them per route.\n\n### 6.6 Consider App Bridge web components\n\nApp Bridge v4 ships native web components (`<ui-modal>`, `<ui-title-bar>`, `<ui-nav-menu>`) that don't require React. For embedded apps, using these instead of their React wrappers shaves ~50KB and removes one re-render layer. The trade-off is they're imperatively controlled, not declaratively.\n\n### 6.7 Audit bundle size\n\nRun before every release:\n\n```bash\n# Vite\nnpx vite-bundle-visualizer\n\n# Webpack\nnpx webpack-bundle-analyzer dist/stats.json\n\n# Or generic\nnpx source-map-explorer dist/**/*.js\n```\n\nFind the top 5 contributors. Polaris should be < 200KB gzipped, your app code < 100KB gzipped, total initial route < 350KB gzipped.\n\n---\n\n## 7. GraphQL strategy\n\n### 7.1 Batch what can be batched\n\nTwo related queries that don't depend on each other? One GraphQL document:\n\n```graphql\nquery DashboardData {\n  shop { name myshopifyDomain }\n  products(first: 10) { edges { node { id title } } }\n}\n```\n\nOne round-trip instead of two. One throttle bucket charge.\n\n### 7.2 Paginate small for embedded views\n\n`first: 250` is cheap on simple queries (id, title) and expensive on nested ones (products → variants → metafields). For embedded admin views, fetch `first: 25` and paginate. Render fast, fetch the next page on `prefetch=\"intent\"`.\n\n### 7.3 Defer non-critical fields with `@defer`\n\nShopify supports the GraphQL `@defer` directive on some fields. Use it for heavy nested data:\n\n```graphql\nquery {\n  product(id: $id) {\n    id\n    title\n    ... @defer { metafields(first: 50) { edges { node { id value } } } }\n  }\n}\n```\n\nServer streams the cheap fields first, then the deferred. Pairs nicely with Remix's `defer`.\n\n### 7.4 Bulk operations for > 250 records\n\nFor exports, audits, \"all products at once\" workloads, never paginate. Use `bulkOperationRunQuery`:\n\n```graphql\nmutation {\n  bulkOperationRunQuery(query: \"\"\"{ products { edges { node { id title } } } }\"\"\") {\n    bulkOperation { id status }\n    userErrors { field message }\n  }\n}\n```\n\nThen poll `currentBulkOperation` until `status == COMPLETED`, download the JSONL. One bucket charge, async, runs in Shopify's infra.\n\n### 7.5 Detect throttling (the 200-OK kind)\n\nThrottled GraphQL responses arrive as HTTP 200 with `errors[*].extensions.code == \"THROTTLED\"`. Check every response:\n\n```ts\nconst response = await admin.graphql(QUERY);\nconst body = await response.json();\n\nif (body.errors?.some(e => e.extensions?.code === \"THROTTLED\")) {\n  const cost = body.extensions.cost.throttleStatus;\n  const waitMs = ((cost.requestedQueryCost - cost.currentlyAvailable) / cost.restoreRate) * 1000;\n  await new Promise(r => setTimeout(r, waitMs));\n  return retry();\n}\n```\n\nLog `extensions.cost.actualQueryCost` and `currentlyAvailable` on every response. If you're consistently above 50% bucket usage, you have an N+1 GraphQL problem.\n\n### 7.6 Query cost budget\n\n| Plan | Bucket size | Restore rate |\n|------|-------------|--------------|\n| Standard | Read `maximumAvailable` | 100 points/sec |\n| Advanced | Read `maximumAvailable` | 200 points/sec |\n| Plus | Read `maximumAvailable` | 1,000 points/sec |\n| Enterprise | Read `maximumAvailable` | 2,000 points/sec |\n\nYour typical query should cost < 100 points. If a single query crosses 500, refactor before shipping.\n\n---\n\n## 8. Image strategy\n\n### 8.1 Use the Shopify Image CDN with size transforms\n\nNever link to the original image URL. Always request a sized variant:\n\n```liquid\n{{ product.featured_image | image_url: width: 600 }}\n```\n\nIn a Remix app, GraphQL returns `image.url(transform: { maxWidth: 600 })`:\n\n```graphql\nimage {\n  url(transform: { maxWidth: 600, preferredContentType: WEBP })\n  altText\n  width\n  height\n}\n```\n\n### 8.2 Always set explicit `width` and `height`\n\nCLS happens when the browser doesn't reserve space for an image and content shifts when it loads. Always:\n\n```tsx\n<img src={url} width={600} height={400} alt={altText} loading=\"lazy\" />\n```\n\nOr via `aspect-ratio` CSS if dimensions are dynamic.\n\n### 8.3 Lazy-load below-the-fold images\n\n```tsx\n<img loading=\"lazy\" decoding=\"async\" ... />\n```\n\nNative lazy-load is supported in every modern browser. Above-the-fold images stay `loading=\"eager\"` so they count toward LCP.\n\n### 8.4 Use modern formats\n\n`preferredContentType: WEBP` (or `AVIF` where available) in the Shopify GraphQL image transform. 30-50% smaller than JPEG, no quality difference.\n\n### 8.5 Preload the LCP image\n\nIf the merchant lands on a product page and the hero image is the LCP element, preload it:\n\n```tsx\nexport const links = () => [\n  { rel: \"preload\", as: \"image\", href: heroImageUrl },\n];\n```\n\nLCP drops by 200-600ms depending on connection.\n\n---\n\n## 9. Caching\n\n### 9.1 Server cache (HTTP)\n\nOn loader responses that are safe to cache:\n\n```ts\nexport function headers() {\n  return {\n    \"Cache-Control\": \"private, max-age=60, stale-while-revalidate=300\",\n    \"Vary\": \"Cookie\",\n  };\n}\n```\n\n- `private` — never cache on shared CDN; merchant data isn't safe to share\n- `max-age=60` — browser cache for 60s\n- `stale-while-revalidate=300` — serve stale for 5 minutes while refreshing in background\n- `Vary: Cookie` — different merchants get different cached responses\n\nFor truly public data (your app's marketing page outside the embedded surface), use `public, max-age=3600, s-maxage=86400` and let the CDN cache it for a day.\n\n### 9.2 CDN edge\n\nIf your platform supports edge caching (Vercel, Cloudflare), tag responses and purge on writes. Vercel `unstable_cache`, Cloudflare Cache API, or just `s-maxage` on responses that are tenant-scoped.\n\n### 9.3 Browser localStorage for non-PII\n\nThe merchant's \"preferred view\" toggle, the \"last sort order\" they chose, UI state — `localStorage`. Never put PII, never put session tokens, never put shop credentials.\n\n### 9.4 In-memory cache on the server\n\nFor data that's expensive to fetch and changes rarely (shop settings, app plan info, currency code), cache in process memory with a 60s TTL:\n\n```ts\nconst cache = new Map<string, { value: any; expires: number }>();\n\nexport async function getShop(shop: string) {\n  const cached = cache.get(shop);\n  if (cached && cached.expires > Date.now()) return cached.value;\n  const value = await fetchShop(shop);\n  cache.set(shop, { value, expires: Date.now() + 60_000 });\n  return value;\n}\n```\n\nIf you have multiple server instances, this only helps per-instance. For shared cache, use Redis (Upstash, Vercel KV).\n\n---\n\n## 10. Database — Prisma\n\n### 10.1 Connection pooling\n\nWithout pooling, every serverless function invocation opens a new Postgres connection. Your database runs out of connections fast.\n\nOptions:\n\n- **Cloudflare Hyperdrive** — global Postgres connection pooler with TLS. Connect Prisma to the Hyperdrive endpoint, get pooled connections with edge latency.\n- **PgBouncer** (managed) — Supabase, Neon, Railway all run PgBouncer in front of Postgres. Use the pooled connection string (`...pgbouncer=true&connection_limit=1`).\n- **Prisma Accelerate** — Prisma's managed pooler + edge cache. Easiest if you don't want to think about it.\n\nIn `schema.prisma`:\n\n```prisma\ndatasource db {\n  provider          = \"postgresql\"\n  url               = env(\"DATABASE_URL\")          // pooled connection\n  directUrl         = env(\"DIRECT_DATABASE_URL\")   // unpooled, for migrations\n}\n```\n\n### 10.2 Fix N+1 with `include` or batched queries\n\n```ts\n// Bad — N+1\nconst products = await prisma.product.findMany();\nfor (const p of products) {\n  p.metafields = await prisma.metafield.findMany({ where: { productId: p.id } });\n}\n\n// Good — one query, joined\nconst products = await prisma.product.findMany({\n  include: { metafields: true },\n});\n```\n\nOr for many-to-many with filters, use `findMany` with `where: { id: { in: ids } }` once, then group in JS.\n\n### 10.3 Index your foreign keys and lookup columns\n\nEvery `where: { shop: \"...\" }` lookup needs an index on `shop`. Without it, your Postgres scans the whole table:\n\n```prisma\nmodel Session {\n  id     String @id\n  shop   String\n  data   String\n\n  @@index([shop])\n}\n```\n\n### 10.4 Use `select` to fetch only what you need\n\n```ts\n// Bad — loads every column including blobs\nconst session = await prisma.session.findUnique({ where: { id }});\n\n// Good\nconst session = await prisma.session.findUnique({\n  where: { id },\n  select: { id: true, shop: true, accessToken: true },\n});\n```\n\n### 10.5 Database location\n\nPut the DB in the same region as the server. Cross-region Postgres adds 60-200ms per query. Two queries serially = LCP burnt.\n\n---\n\n## 11. Webhook handlers\n\n### 11.1 Return 200 in < 2 seconds, always\n\nShopify retries webhooks after 5 seconds. If you do real work synchronously, you'll be retried, processed twice, throttled.\n\nPattern:\n\n```ts\nexport async function action({ request }) {\n  const { topic, shop, payload } = await authenticate.webhook(request);\n\n  // Push to queue. Return immediately.\n  await queue.enqueue({ topic, shop, payload, webhookId: request.headers.get(\"X-Shopify-Webhook-Id\") });\n\n  return new Response(null, { status: 200 });\n}\n```\n\n### 11.2 Queue choices\n\n- **Inngest** — easiest. Type-safe, retries, observability built in. Free tier covers most apps.\n- **Trigger.dev** — similar, with longer-running jobs and a workflow visualizer.\n- **BullMQ + Redis** — self-hosted, max control. Use if you already have Redis.\n- **AWS SQS / Cloudflare Queues** — if you live in that ecosystem.\n- **DB-backed queue** (`pg-boss`, `graphile-worker`) — fewest moving pieces if you already have Postgres.\n\nThe pattern is the same regardless: webhook handler does HMAC verify, persists the job, returns 200. A worker picks up the job.\n\n### 11.3 Idempotency\n\nWebhooks are at-least-once. Your handler will be called twice on the same event sometimes.\n\n```ts\nconst webhookId = request.headers.get(\"X-Shopify-Webhook-Id\");\nconst existing = await prisma.processedWebhook.findUnique({ where: { id: webhookId }});\nif (existing) return new Response(null, { status: 200 });\n\nawait prisma.processedWebhook.create({ data: { id: webhookId, processedAt: new Date() }});\n// process the work\n```\n\n### 11.4 HMAC verify on the raw body, not the parsed JSON\n\nIf you `await request.json()` before HMAC, Express/Remix may have mutated the body and your HMAC will fail. Use `authenticate.webhook(request)` from the Shopify Remix package — it does this correctly.\n\n---\n\n## 12. Measuring\n\n### 12.1 Web Vitals API (the one BFS cares about)\n\nWire up the App Bridge Web Vitals API and send to your monitoring service:\n\n```ts\nimport { shopify } from \"@shopify/app-bridge-react\";\n\nshopify.webVitals.onReport((metric) => {\n  // metric.name: 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'\n  // metric.value: number\n  // metric.attribution: detailed breakdown\n  sendToMonitoring(metric);\n});\n```\n\nWithout this wired up, BFS shows nothing and you can't improve what you don't measure.\n\n### 12.2 Server monitoring\n\n- **Sentry** — errors + performance traces. Free tier is generous.\n- **Datadog** — pricier, deeper. APM, RUM, logs in one place.\n- **Axiom** — log-first, cheap, great for serverless.\n- **OpenTelemetry + your own backend** — for the curious.\n\nTrack p50, p95, p99 latency on every loader and action. Alert when p95 crosses 400ms (well before the BFS gate of 500ms).\n\n### 12.3 Bundle size in CI\n\nAdd a bundle-size check that fails CI:\n\n```json\n\"scripts\": {\n  \"size\": \"size-limit\"\n},\n\"size-limit\": [\n  { \"path\": \"build/client/**/*.js\", \"limit\": \"350 KB\" }\n]\n```\n\n### 12.4 BFS audit tooling\n\nShopify's Partners dashboard shows your Web Vitals data after the 100-call minimum is hit. Check it weekly. If you see a regression, bisect against your deploys.\n\n---\n\n## 13. 25 performance rules (cheat sheet)\n\n1. App Bridge CDN script in `<head>` before your bundle, always.\n2. Polaris CSS imported exactly once at the root.\n3. Deep-import Polaris components if your bundler tree-shakes badly.\n4. Lazy-load any component over 50KB.\n5. Lazy-load icons; deep-import from `@shopify/polaris-icons`.\n6. Render `<AppProvider>` and `<Frame>` exactly once.\n7. Loader awaits only what's needed for first paint; everything else is `defer`'d.\n8. `prefetch=\"intent\"` on every internal `<Link>`.\n9. Cache-Control headers on every loader response (private, max-age, SWR).\n10. Server runs on always-on infra (or warm pings) — no scale-to-zero for the embedded surface.\n11. Server is in the same region as your DB.\n12. DB connections pooled (Hyperdrive, PgBouncer, or Accelerate).\n13. Every Prisma `where` column is indexed.\n14. Prisma `select` only what you need; no SELECT *.\n15. No N+1 — use `include` or batched `findMany`.\n16. GraphQL queries batched where possible; `first: 25` for embedded views.\n17. GraphQL responses checked for `extensions.code == \"THROTTLED\"` on every call.\n18. Bulk operations for > 250 records, never paginate.\n19. Images via Shopify Image CDN with `maxWidth` and `WEBP`.\n20. Every `<img>` has explicit `width`, `height`, `loading=\"lazy\"` (except LCP).\n21. LCP image preloaded via `<link rel=\"preload\">`.\n22. Webhook handlers return 200 in < 2 seconds; heavy work in queue.\n23. Webhook handlers idempotent via `X-Shopify-Webhook-Id`.\n24. Web Vitals API wired to monitoring; check weekly.\n25. Bundle size budget enforced in CI; current threshold 350KB gzipped initial route.\n\n---\n\n## 14. Decision tree\n\n```\nEmbedded app is slow. Where do I start?\n│\n├── Is the FIRST load slow (cold)?\n│   ├── Yes → Server cold start\n│   │        Switch to always-on infra OR add warm pings\n│   │        Verify with curl timing on cold vs warm: curl -w \"%{time_total}\\n\" -o /dev/null -s URL\n│   │\n│   └── No → Continue\n│\n├── Is EVERY load slow?\n│   ├── Yes → Likely bundle size or loader latency\n│   │\n│   │   Check bundle:\n│   │   ├── Run vite-bundle-visualizer or webpack-bundle-analyzer\n│   │   ├── If Polaris > 250KB gzipped → deep-import, lazy-load heavy components\n│   │   ├── If icons > 100KB → deep-import per icon\n│   │   └── If your app code > 200KB → split routes, lazy-load\n│   │\n│   │   Check loader:\n│   │   ├── Log loader duration per route\n│   │   ├── If > 400ms → look for serial awaits, switch to defer + Promise.all\n│   │   ├── If > 1s → GraphQL or DB problem (sections below)\n│   │   └── If GraphQL — check actualQueryCost in extensions.cost\n│   │\n│   └── No → Continue\n│\n├── Is NAVIGATION slow (clicking around)?\n│   ├── Yes → Missing prefetch\n│   │        Add prefetch=\"intent\" to all internal Links\n│   │        Add Cache-Control headers to stable loader responses\n│   │\n│   └── No → Continue\n│\n├── Is the APP unresponsive during interaction (high INP)?\n│   ├── Yes → Main thread blocked\n│   │        ├── Heavy synchronous work in event handlers — break into chunks or move to worker\n│   │        ├── Re-rendering large lists on every keystroke — virtualize (react-virtual, IndexTable virtualization)\n│   │        └── Large state updates — useDeferredValue or useTransition\n│   │\n│   └── No → Continue\n│\n├── Is the UI JUMPY (high CLS)?\n│   ├── Yes → Layout shifts\n│   │        ├── Images missing width/height\n│   │        ├── Skeletons not matching final dimensions\n│   │        ├── Fonts loading and re-flowing (use font-display: optional or swap with size-adjust)\n│   │        └── Late-loading ads/embeds pushing content\n│   │\n│   └── No → Continue\n│\n├── Are SERVER metrics (p95) too high?\n│   ├── DB queries slow?\n│   │   ├── Check Prisma logs (DEBUG=prisma:query)\n│   │   ├── Add indexes on every WHERE column\n│   │   ├── Fix N+1 with include\n│   │   └── Move DB to same region as server\n│   │\n│   ├── GraphQL slow?\n│   │   ├── Check actualQueryCost — if > 100, prune fields\n│   │   ├── Batch where possible\n│   │   └── Switch to bulk operations for > 250 records\n│   │\n│   └── Cold start?\n│       └── See top of tree\n│\n└── Are WEBHOOKS getting retried?\n    └── Handler taking > 2 seconds\n         ├── HMAC verify + 200 OK first\n         ├── Push work to queue\n         └── Idempotent on X-Shopify-Webhook-Id\n```\n\n---\n\n## 15. Pre-submit performance checklist\n\nRun through this before BFS submission:\n\n- [ ] App Bridge Web Vitals API wired, sending to monitoring\n- [ ] 100+ launches recorded over 28 days (let it run before submitting if new)\n- [ ] LCP p75 ≤ 2.5s in Web Vitals dashboard\n- [ ] INP p75 ≤ 200ms\n- [ ] CLS p75 ≤ 0.1\n- [ ] Server p95 latency ≤ 400ms (cushion below BFS 500ms)\n- [ ] Server failure rate ≤ 0.05% (cushion below BFS 0.1%)\n- [ ] Bundle size for initial route ≤ 350KB gzipped\n- [ ] Polaris CSS imported exactly once at root\n- [ ] No `<img>` without explicit `width` and `height`\n- [ ] Images served from Shopify Image CDN with WEBP\n- [ ] All internal `<Link>` use `prefetch=\"intent\"`\n- [ ] Loaders use `defer` for non-critical data\n- [ ] Cache-Control headers on stable loader responses\n- [ ] Server runs on always-on infra\n- [ ] DB connection pooling enabled\n- [ ] All Prisma WHERE columns indexed\n- [ ] No N+1 in any loader (verified with Prisma query logs)\n- [ ] GraphQL throttle detection in every request wrapper\n- [ ] Webhook handlers return 200 in < 2s, queue heavy work\n- [ ] Webhook handlers idempotent via `X-Shopify-Webhook-Id`\n- [ ] Storefront extensions (if any) ≤ 50KB gzipped per surface\n- [ ] Storefront Lighthouse delta ≤ 5 points\n\nIf all checked, submit. If any unchecked, fix first — BFS rejection on perf burns a 28-day re-measurement window you don't want to repeat.\n\n---\n\n## Closing principle\n\nPerformance in a Shopify embedded app is the sum of three latencies the merchant feels: **server**, **bundle**, **render**. Optimize all three. Most apps fix one and ignore the other two, then wonder why BFS still rejects them. Wire up Web Vitals first so you can see the truth — then the fixes above are a checklist, not a guess.\n"
}

SHA-256 of public snapshot: bbfcc8794c2eead4b629d2d9dc06eb5e835ec0a64007c15f90e37b9c220b1508