Skip to main content

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

MetricWhat It MeasuresGood 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
Regional Performance Data (Winter '26)

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);
warning

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;
}
tip

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
}
danger

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

  1. No synchronous script injection -- All JavaScript must load asynchronously
  2. Minimal storefront impact -- Your app should add less than 100ms to page load time
  3. Efficient API usage -- Stay well within rate limits; use bulk operations for large datasets
  4. No layout shifts -- Reserve space for dynamically loaded content
  5. 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"
info

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

AreaActionPriority
JavaScriptAsync loading, code splitting, tree shakingCritical
ImagesShopify CDN transforms, lazy loading, WebPCritical
API CallsCaching, batching, bulk operationsHigh
DatabaseIndexing, connection pooling, query optimizationHigh
CSSCritical CSS inline, async stylesheet loadingMedium
Fontsfont-display: swap, preload critical fontsMedium
MonitoringRUM, error tracking, alertingOngoing

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.