Performance Optimization
Performance is not optional in the Shopify ecosystem. Shopify enforces strict performance requirements during app review, and merchants will uninstall apps that slow down their storefronts. In this module, you will learn how to build apps that are fast by design, monitor real-world performance metrics, and optimize every layer of your application stack.
Web Vitals Monitoring
Google's Core Web Vitals are the industry standard for measuring user-perceived performance. Shopify tracks these metrics for storefronts and penalizes apps that degrade them.
The Three Core Web Vitals
| Metric | What It Measures | Good Threshold |
|---|---|---|
| LCP (Largest Contentful Paint) | Loading performance | < 2.5 seconds |
| INP (Interaction to Next Paint) | Interactivity responsiveness | < 200 ms |
| CLS (Cumulative Layout Shift) | Visual stability | < 0.1 |
Starting with the Winter '26 release, Shopify provides regional Web Vitals data in the Partner Dashboard. You can now see how your app performs across different geographic regions, enabling you to identify and fix region-specific bottlenecks such as CDN coverage gaps or slow API endpoints in certain data centers.
Implementing Web Vitals Tracking
Add real-user monitoring (RUM) to your app's storefront components:
// web-vitals-monitor.js
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
shopDomain: window.Shopify?.shop,
});
// Use sendBeacon for reliability during page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/vitals', body);
} else {
fetch('/api/analytics/vitals', {
body,
method: 'POST',
keepalive: true,
});
}
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Never inject synchronous JavaScript into a merchant's storefront. All storefront scripts must load asynchronously using defer or async attributes, or be loaded via Shopify's Script Tag API with the display_scope set appropriately.
Performance Budgets
Establish performance budgets for your app's storefront impact:
// performance-budget.config.js
export default {
budgets: [
{
path: '/apps/your-app/*',
resourceSizes: [
{ resourceType: 'script', budget: 50 }, // 50 KB max JS
{ resourceType: 'stylesheet', budget: 20 }, // 20 KB max CSS
{ resourceType: 'image', budget: 100 }, // 100 KB max images
{ resourceType: 'total', budget: 200 }, // 200 KB total
],
resourceCounts: [
{ resourceType: 'third-party', budget: 3 }, // Max 3 third-party requests
],
},
],
};
Lazy Loading and Code Splitting
Shopify apps often contain complex admin interfaces. Loading everything upfront wastes bandwidth and slows down initial render.
Route-Based Code Splitting with React
// app/routes.jsx
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { SkeletonPage } from '@shopify/polaris';
// Lazy load heavy route components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const ProductSync = lazy(() => import('./pages/ProductSync'));
const Analytics = lazy(() => import('./pages/Analytics'));
const BulkEditor = lazy(() => import('./pages/BulkEditor'));
function AppRoutes() {
return (
<Suspense fallback={<SkeletonPage primaryAction />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/sync" element={<ProductSync />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/bulk-edit" element={<BulkEditor />} />
</Routes>
</Suspense>
);
}
Component-Level Lazy Loading
For heavy components within a page, use intersection observer patterns:
// components/LazyChart.jsx
import { useEffect, useRef, useState } from 'react';
export function LazyChart({ data }) {
const containerRef = useRef(null);
const [ChartComponent, setChartComponent] = useState(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
import('./HeavyChartLibrary').then((mod) => {
setChartComponent(() => mod.Chart);
});
observer.disconnect();
}
},
{ rootMargin: '200px' } // Start loading 200px before visible
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div ref={containerRef} style={{ minHeight: 300 }}>
{ChartComponent ? <ChartComponent data={data} /> : <SkeletonChart />}
</div>
);
}
Image Optimization for Storefront Components
When your app renders images on the storefront, always use Shopify's image CDN transformations:
{% comment %} Use Shopify's image_url filter for automatic CDN optimization {% endcomment %}
{% assign optimized_url = image | image_url: width: 400, height: 400, crop: 'center' %}
<img
src="{{ optimized_url }}"
loading="lazy"
decoding="async"
width="400"
height="400"
alt="{{ image.alt | escape }}"
/>
CDN and Caching Strategies
HTTP Caching Headers
Configure your app server to return proper cache headers:
// middleware/caching.js
export function cachingMiddleware(req, res, next) {
const path = req.path;
if (path.startsWith('/assets/')) {
// Static assets: cache for 1 year with immutable
res.set('Cache-Control', 'public, max-age=31536000, immutable');
} else if (path.startsWith('/api/storefront/')) {
// Storefront API responses: short cache with stale-while-revalidate
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
} else if (path.startsWith('/api/admin/')) {
// Admin API responses: no cache (contains merchant-specific data)
res.set('Cache-Control', 'private, no-cache, no-store');
}
next();
}
Redis Caching for GraphQL Responses
Cache frequently accessed Shopify API data to reduce latency and API rate limit consumption:
// services/cached-shopify-client.js
import { createClient } from 'redis';
import crypto from 'crypto';
const redis = createClient({ url: process.env.REDIS_URL });
export async function cachedGraphQLQuery(session, query, variables, ttl = 300) {
const cacheKey = `shopify:${session.shop}:${crypto
.createHash('md5')
.update(JSON.stringify({ query, variables }))
.digest('hex')}`;
// Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Fetch from Shopify
const client = new shopify.clients.Graphql({ session });
const response = await client.query({ data: { query, variables } });
// Cache the response
await redis.setEx(cacheKey, ttl, JSON.stringify(response.body));
return response.body;
}
Use different TTL values based on data volatility. Product data can be cached for 5 minutes, but inventory levels should have a TTL of 30 seconds or less. Webhook-driven cache invalidation is the gold standard -- invalidate cached data when you receive relevant webhooks like products/update.
Database Query Optimization
Indexing Strategies
Your app's database is often the primary bottleneck. Always index columns used in WHERE clauses and JOIN conditions:
-- Essential indexes for a typical Shopify app
CREATE INDEX idx_shops_domain ON shops(shopify_domain);
CREATE INDEX idx_sessions_shop_id ON sessions(shop_id);
CREATE INDEX idx_sync_jobs_shop_status ON sync_jobs(shop_id, status);
CREATE INDEX idx_products_shop_shopify_id ON products(shop_id, shopify_product_id);
-- Composite index for common query patterns
CREATE INDEX idx_orders_shop_date ON orders(shop_id, created_at DESC);
N+1 Query Prevention with Prisma
// BAD: N+1 queries
const shops = await prisma.shop.findMany();
for (const shop of shops) {
// This runs a separate query for EACH shop
const products = await prisma.product.findMany({
where: { shopId: shop.id },
});
}
// GOOD: Eager loading with include
const shops = await prisma.shop.findMany({
include: {
products: {
where: { syncStatus: 'active' },
take: 100,
},
},
});
Connection Pooling
Configure your database connection pool based on your hosting environment:
// prisma/schema.prisma connection string with pooling
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
// For serverless environments (Vercel, AWS Lambda)
// Use PgBouncer or Prisma Accelerate for connection pooling
}
Serverless environments create a new database connection on every cold start. Without connection pooling, you can exhaust your database's connection limit during traffic spikes. Always use an external connection pooler like PgBouncer, Supabase's built-in pooler, or Prisma Accelerate when deploying to serverless platforms.
Shopify's Performance Requirements for App Review
Shopify's app review team evaluates performance as a pass/fail criterion. Failing to meet these requirements will result in your app being rejected.
Key Requirements
- No synchronous script injection -- All JavaScript must load asynchronously
- Minimal storefront impact -- Your app should add less than 100ms to page load time
- Efficient API usage -- Stay well within rate limits; use bulk operations for large datasets
- No layout shifts -- Reserve space for dynamically loaded content
- Fast admin loading -- App pages within the Shopify admin should load in under 3 seconds
Pre-Submission Performance Checklist
# Run Lighthouse audit against your app's storefront components
npx lighthouse https://test-store.myshopify.com \
--only-categories=performance \
--output=json \
--output-path=./lighthouse-report.json
# Check bundle sizes
npx webpack-bundle-analyzer stats.json
# Profile API calls
shopify app dev --verbose 2>&1 | grep "GraphQL"
Shopify provides a Storefront Performance Dashboard in the Partner Dashboard that shows exactly how your app impacts the Web Vitals of stores where it is installed. Monitor this dashboard continuously after launch, not just before app review.
Optimization Checklist Summary
| Area | Action | Priority |
|---|---|---|
| JavaScript | Async loading, code splitting, tree shaking | Critical |
| Images | Shopify CDN transforms, lazy loading, WebP | Critical |
| API Calls | Caching, batching, bulk operations | High |
| Database | Indexing, connection pooling, query optimization | High |
| CSS | Critical CSS inline, async stylesheet loading | Medium |
| Fonts | font-display: swap, preload critical fonts | Medium |
| Monitoring | RUM, error tracking, alerting | Ongoing |
Performance optimization is an ongoing discipline, not a one-time task. Build monitoring into your app from day one, set performance budgets, and treat regressions as bugs that block releases. Merchants depend on fast storefronts for their revenue, and your app must respect that constraint.