liquid
{% comment %} Optimized: Cache collection filter logic instead of recalculating per product {% endcomment %}
{% assign active_filters = collection.metafields.custom.filter_config.value %}
{% capture filtered_products %}
{% for product in collection.products limit: 24 %}
{% if product.tags contains active_filters %}
{{ product.id | json }}
{% endif %}
{% endfor %}
{% endcapture %}
<script>
window.__PRODUCT_IDS = {{ filtered_products | strip | default: '[]' }};
</script>
**Why this works:** The `capture` block executes once per template render, storing the result in memory. Subsequent references read from the captured variable instead of re-evaluating loops. Limiting collection page sizes to 24 items prevents server thread blocking during pagination. Use the Shopify Theme Inspector Chrome extension to identify tags consuming >15ms of render time.
### Step 2: Asset Pipeline & Critical Rendering Path
Images typically constitute 50% to 80% of total page weight. Shopify's CDN handles resizing and format conversion, but only if source inputs are properly constrained.
**Architecture Decision:** Declare explicit target dimensions in template filters, prioritize LCP elements with `fetchpriority`, and defer off-screen media.
```liquid
{% comment %} Production-ready image component with explicit sizing and priority routing {% endcomment %}
<img
src="{{ product.featured_image | image_url: width: 800, height: 800, crop: 'center' }}"
srcset="{{ product.featured_image | image_url: width: 400 }} 400w,
{{ product.featured_image | image_url: width: 800 }} 800w,
{{ product.featured_image | image_url: width: 1200 }} 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
loading="{{ is_lcp ? 'eager' : 'lazy' }}"
fetchpriority="{{ is_lcp ? 'high' : 'low' }}"
alt="{{ product.featured_image.alt | escape }}"
width="800"
height="800"
/>
Why this works: image_url with explicit dimensions prevents CDN fallback to master (full-resolution) processing. fetchpriority="high" instructs the browser to queue the LCP image ahead of competing resources, routinely reducing LCP by 200â400ms. Explicit width and height attributes reserve layout space, preventing CLS from late-loading media.
Step 3: Third-Party Script Isolation
Global script injection is the primary cause of INP degradation. Each app typically registers event listeners and executes initialization logic on every page load, regardless of route relevance.
Architecture Decision: Implement route-based script loading using Shopify's request.page_type and dynamic import patterns. Replace app functionality with native OS 2.0 sections and metaobjects where possible.
// route-script-loader.ts
type RouteType = 'index' | 'product' | 'collection' | 'cart' | 'page' | 'blog';
interface ScriptConfig {
src: string;
routes: RouteType[];
defer?: boolean;
}
const APP_SCRIPTS: ScriptConfig[] = [
{ src: '/apps/reviews/widget.js', routes: ['product', 'collection'] },
{ src: '/apps/loyalty/tracking.js', routes: ['index', 'cart'] },
{ src: '/apps/chat/launcher.js', routes: ['index', 'product', 'collection', 'cart'] }
];
export function initializeRouteScripts() {
const currentRoute = document.documentElement.dataset.pageType as RouteType;
APP_SCRIPTS
.filter(config => config.routes.includes(currentRoute))
.forEach(config => {
const script = document.createElement('script');
script.src = config.src;
if (config.defer) script.defer = true;
script.setAttribute('data-app-scope', 'route-isolated');
document.head.appendChild(script);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeRouteScripts);
} else {
initializeRouteScripts();
}
Why this works: Route scoping prevents unused JavaScript from parsing and executing. The data-app-scope attribute enables automated coverage audits. Native metafields and metaobjects now handle structured data that previously required dedicated apps, eliminating entire script bundles.
Shopify's hosted checkout is heavily optimized. Performance regression occurs when custom logic bypasses sandbox constraints or introduces synchronous operations.
Architecture Decision: Enforce strict performance budgets on Checkout UI Extensions. Avoid external font loading, uncached API calls on mount, and deep component trees. Use checkout.liquid only on legacy Plus setups; migrate to Extensibility APIs.
// checkout-extension.tsx
import { extend, render } from '@shopify/ui-extensions-react/checkout';
extend('purchase.checkout.block.render', (root) => {
// Performance budget: < 50ms render time, zero external fetches on mount
render(root, <OptimizedUpsellBlock />);
});
function OptimizedUpsellBlock() {
// Use pre-fetched cart data instead of live API calls
const cartData = useCart();
const upsellItems = useMemo(() =>
cartData.lines.filter(line => line.merchandise.product.tags.includes('upsell'))
, [cartData.lines]);
return upsellItems.length > 0 ? (
<div className="upsell-container">
{upsellItems.map(item => <UpsellCard key={item.id} data={item} />)}
</div>
) : null;
}
Why this works: The sandboxed environment restricts DOM manipulation but doesn't eliminate execution cost. Pre-fetching cart data and memoizing computations prevents re-renders. Avoiding external fonts inside extensions eliminates layout thrashing during checkout flow.
Pitfall Guide
1. Chasing Synthetic Scores Over CrUX
Explanation: Optimizing for Lighthouse lab scores creates false confidence. Lab environments use predictable network conditions and high-end CPUs, masking real-world INP and CLS issues.
Fix: Anchor optimization targets to CrUX field data thresholds (LCP < 2.5s, INP < 200ms, CLS < 0.1). Use Treo.sh or SpeedCurve to track URL-level field performance after deployments.
2. Global App Injection Without Route Scoping
Explanation: Third-party scripts loading on irrelevant pages increase payload weight and event listener overhead, directly degrading INP.
Fix: Audit scripts via Chrome DevTools Coverage tab. Identify files with >80% unused code. Implement route-based loading or request page-specific configuration from app vendors.
3. Using img_url: 'master' or Oversized Uploads
Explanation: Uploading 4000px images and relying on master forces the CDN to process full-resolution files on every request, increasing TTFB and bandwidth costs.
Fix: Enforce upload size limits (max 2000px width). Use explicit image_url dimensions and srcset for responsive delivery. Always declare width/height attributes.
Explanation: Server-side Liquid overhead doesn't appear in the Network tab. Teams focus on client-side metrics while TTFB remains >800ms due to nested loops and redundant renders.
Fix: Use Shopify Theme Inspector to profile Liquid tag execution. Flatten collection iterations, cache expensive calculations with capture, and limit pagination sizes. Audit theme.liquid for global logic blocks.
5. Font Swap-Induced CLS
Explanation: Late-loading web fonts trigger layout shifts when the browser swaps fallback typography. This consistently violates CLS thresholds.
Fix: Apply font-display: optional in theme CSS. This instructs the browser to use the fallback font if the web font isn't immediately available, eliminating swap-induced shifts entirely.
6. Checkout Extension Component Over-Nesting
Explanation: Deep component trees and synchronous data fetching inside sandboxed extensions increase render time, directly impacting checkout conversion.
Fix: Flatten component hierarchies. Pre-fetch data during cart initialization. Avoid external font loading inside extensions. Enforce a <50ms render budget per extension block.
7. App Chaining for Single Business Logic
Explanation: Relying on 3+ apps to handle discount rules, pricing logic, or product relationships creates execution order conflicts and failure cascades.
Fix: Consolidate into custom Liquid sections or Shopify Functions when app count exceeds three for a single workflow. Native metafields and metaobjects often replace entire app dependencies.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Store with <5 apps, stable TTFB | Route-scoped script loading + image optimization | Targets controllable 70% without architectural overhaul | Low (development hours only) |
| Store with 8+ apps, poor INP/CLS | App consolidation + native metafield replacement | Eliminates global script injection and execution conflicts | Medium (SaaS fee reduction + dev time) |
| Shopify Plus with legacy checkout | Migrate to Checkout Extensibility + sandboxed extensions | Removes deprecated checkout.liquid risks and enforces performance budgets | High (migration effort, long-term stability) |
| Seasonal campaign with temporary scripts | Route-isolated injection + post-campaign cleanup | Prevents permanent performance debt from temporary marketing tools | Low (automation overhead) |
Configuration Template
<!-- layout/theme.liquid -->
<!doctype html>
<html lang="{{ request.locale.iso_code }}" data-page-type="{{ request.page_type }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title }}</title>
{% comment %} Critical font loading with CLS prevention {% endcomment %}
<style>
@font-face {
font-family: 'CustomSans';
src: url('{{ 'custom-sans.woff2' | asset_url }}') format('woff2');
font-display: optional;
font-weight: 400 700;
}
body { font-family: system-ui, -apple-system, 'CustomSans', sans-serif; }
</style>
{{ content_for_header }}
</head>
<body>
{{ content_for_layout }}
{% comment %} Route-scoped script initialization {% endcomment %}
<script src="{{ 'route-loader.js' | asset_url }}" defer></script>
</body>
</html>
// assets/route-loader.ts
interface RouteConfig {
[key: string]: () => void;
}
const ROUTE_HANDLERS: RouteConfig = {
product: () => import('./product/init').then(m => m.init()),
collection: () => import('./collection/init').then(m => m.init()),
cart: () => import('./cart/init').then(m => m.init()),
index: () => import('./home/init').then(m => m.init())
};
document.addEventListener('DOMContentLoaded', () => {
const route = document.documentElement.dataset.pageType;
if (route && ROUTE_HANDLERS[route]) {
ROUTE_HANDLERS[route]();
}
});
Quick Start Guide
- Install Shopify Theme Inspector and run it on your highest-traffic product and collection pages. Identify Liquid tags exceeding 15ms render time.
- Audit image pipeline: Replace all
img_url: 'master' references with explicit image_url dimensions. Add fetchpriority="high" to hero/LCP images and loading="lazy" to below-fold media.
- Run Chrome DevTools Coverage (Ctrl+Shift+P â "Coverage"). Reload pages and flag JavaScript files with >80% unused code. Map these to installed apps and request route-specific loading configurations.
- Apply
font-display: optional to all @font-face declarations in your theme CSS. Verify CLS elimination using Lighthouse field data simulation.
- Schedule a quarterly audit: Export CrUX data from Google Search Console, run a fresh Coverage scan, and compare against your baseline. Document drift and prioritize fixes before conversion impact occurs.