Performance Optimization Intermediate 12 min read

Code Splitting and Lazy Loading

Optimize your React Shopify theme by splitting code into smaller chunks. Learn dynamic imports, route-based splitting, and component-level lazy loading strategies.

A Shopify theme with React can easily balloon to hundreds of kilobytes of JavaScript. When customers visit your store, they shouldn’t have to download code for the cart drawer, account pages, and product zoom—all before seeing the first product. Code splitting lets you break your bundle into smaller chunks that load on demand.

Why Code Splitting Matters

Consider a typical React Shopify theme bundle:

┌─────────────────────────────────────────────────────────────────┐
│ BEFORE CODE SPLITTING │
├─────────────────────────────────────────────────────────────────┤
│ │
│ main.js (450KB) │
│ ├── React + ReactDOM ................ 140KB │
│ ├── Framer Motion ................... 30KB │
│ ├── Zustand + state logic ........... 15KB │
│ ├── All components .................. 180KB │
│ │ ├── Header/Navigation │
│ │ ├── Product page components │
│ │ ├── Cart drawer │
│ │ ├── Search modal │
│ │ ├── Account components │
│ │ └── Footer │
│ └── Utilities + helpers ............. 85KB │
│ │
│ Every page loads EVERYTHING │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ AFTER CODE SPLITTING │
├─────────────────────────────────────────────────────────────────┤
│ │
│ main.js (95KB) - loads on every page │
│ ├── React + ReactDOM ................ 140KB (shared) │
│ ├── Core UI components .............. 40KB │
│ └── Essential utilities ............. 15KB │
│ │
│ product.js (65KB) - loads on product pages │
│ cart.js (35KB) - loads when cart opens │
│ search.js (25KB) - loads when search opens │
│ account.js (45KB) - loads on account pages │
│ │
│ Each page loads only what it needs │
│ │
└─────────────────────────────────────────────────────────────────┘

The goal: reduce initial JavaScript by 50-70%, loading additional code only when needed.

Dynamic Imports with React.lazy

React’s built-in lazy function creates components that load on demand:

src/entries/main.tsx
import { lazy, Suspense } from 'react';
// These components are split into separate chunks
const CartDrawer = lazy(() => import('@/components/cart/CartDrawer'));
const SearchModal = lazy(() => import('@/components/search/SearchModal'));
const QuickView = lazy(() => import('@/components/product/QuickView'));
// Loading fallback
function LoadingSpinner() {
return (
<div className="loading-spinner" aria-label="Loading">
<span className="spinner" />
</div>
);
}
export function App() {
const { isCartOpen } = useUIState();
const { isSearchOpen } = useUIState();
const { quickViewProduct } = useUIState();
return (
<>
{/* Always-visible header */}
<Header />
{/* Lazy-loaded overlays */}
<Suspense fallback={<LoadingSpinner />}>
{isCartOpen && <CartDrawer />}
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
{isSearchOpen && <SearchModal />}
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
{quickViewProduct && <QuickView product={quickViewProduct} />}
</Suspense>
</>
);
}

When isCartOpen becomes true, React fetches the CartDrawer chunk, parses it, and renders. Until then, that code never downloads.

Naming Chunks for Debugging

Vite/Webpack can name chunks for easier debugging:

// Named chunks appear in Network tab as "cart.js" instead of "chunk-abc123.js"
const CartDrawer = lazy(() =>
import(/* webpackChunkName: "cart" */ '@/components/cart/CartDrawer')
);
// Vite uses the file path by default, but you can configure it
const SearchModal = lazy(() =>
import('@/components/search/SearchModal')
);

Configure Vite for meaningful chunk names:

vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
// Name chunks based on their entry point
chunkFileNames: (chunkInfo) => {
const facadeModuleId = chunkInfo.facadeModuleId || '';
if (facadeModuleId.includes('/cart/')) return 'cart-[hash].js';
if (facadeModuleId.includes('/search/')) return 'search-[hash].js';
if (facadeModuleId.includes('/account/')) return 'account-[hash].js';
if (facadeModuleId.includes('/product/')) return 'product-[hash].js';
return '[name]-[hash].js';
},
},
},
},
});

Page-Based Splitting Strategy

For a Shopify theme, split by page type:

src/entries/index.ts
/**
* Entry point router - loads the appropriate module based on page type.
* This file stays small; heavy components are loaded dynamically.
*/
// Detect page type from body classes or data attributes
function getPageType(): string {
const body = document.body;
if (body.classList.contains('template-product')) return 'product';
if (body.classList.contains('template-collection')) return 'collection';
if (body.classList.contains('template-cart')) return 'cart';
if (body.classList.contains('template-customers-login')) return 'login';
if (body.classList.contains('template-customers-account')) return 'account';
if (body.classList.contains('template-search')) return 'search';
return 'default';
}
// Load page-specific modules
async function initializePage() {
const pageType = getPageType();
// Always load core functionality
const { initializeCore } = await import('./core');
initializeCore();
// Load page-specific modules
switch (pageType) {
case 'product':
const { initializeProductPage } = await import('./pages/product');
initializeProductPage();
break;
case 'collection':
const { initializeCollectionPage } = await import('./pages/collection');
initializeCollectionPage();
break;
case 'cart':
const { initializeCartPage } = await import('./pages/cart');
initializeCartPage();
break;
case 'login':
case 'account':
const { initializeAccountPages } = await import('./pages/account');
initializeAccountPages();
break;
case 'search':
const { initializeSearchPage } = await import('./pages/search');
initializeSearchPage();
break;
}
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}

Component-Level Splitting

Split heavy components that aren’t immediately visible:

src/components/product/ProductPage/ProductPage.tsx
import { lazy, Suspense, useState } from 'react';
import { ProductGallery } from './ProductGallery';
import { ProductForm } from './ProductForm';
import { ProductInfo } from './ProductInfo';
// Heavy components loaded on interaction
const ProductZoom = lazy(() => import('./ProductZoom'));
const ProductReviews = lazy(() => import('./ProductReviews'));
const RecentlyViewed = lazy(() => import('@/components/shared/RecentlyViewed'));
// Skeleton components for loading states
import { ReviewsSkeleton, RecentlyViewedSkeleton } from './skeletons';
export function ProductPage({ product }: ProductPageProps) {
const [showZoom, setShowZoom] = useState(false);
const [activeImage, setActiveImage] = useState(0);
return (
<div className="product-page">
{/* Critical above-the-fold content - not lazy loaded */}
<div className="product-main">
<ProductGallery
images={product.images}
onImageClick={(index) => {
setActiveImage(index);
setShowZoom(true);
}}
/>
<ProductInfo product={product} />
<ProductForm product={product} />
</div>
{/* Zoom modal - lazy loaded when opened */}
{showZoom && (
<Suspense fallback={<div className="zoom-loading" />}>
<ProductZoom
images={product.images}
initialIndex={activeImage}
onClose={() => setShowZoom(false)}
/>
</Suspense>
)}
{/* Below-the-fold content - lazy loaded */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={product.id} />
</Suspense>
<Suspense fallback={<RecentlyViewedSkeleton />}>
<RecentlyViewed currentProductId={product.id} />
</Suspense>
</div>
);
}

Preloading for Better UX

Preload chunks before they’re needed to eliminate loading delays:

src/hooks/usePreload.ts
import { useEffect } from 'react';
/**
* Preload a dynamic import when a condition is met.
* Use for components that will likely be needed soon.
*/
export function usePreload(
importFn: () => Promise<unknown>,
condition: boolean
) {
useEffect(() => {
if (condition) {
importFn();
}
}, [condition, importFn]);
}
// Usage: Preload cart drawer when hovering over cart icon
export function CartIcon() {
const [isHovered, setIsHovered] = useState(false);
// Start loading cart drawer code on hover
usePreload(
() => import('@/components/cart/CartDrawer'),
isHovered
);
return (
<button
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={openCart}
>
<CartIconSvg />
</button>
);
}

Preload on route intent:

src/components/product/ProductCard.tsx
import { useCallback } from 'react';
export function ProductCard({ product }: ProductCardProps) {
// Preload product page code when card is in viewport
const preloadProductPage = useCallback(() => {
import('@/entries/pages/product');
}, []);
return (
<a
href={`/products/${product.handle}`}
onMouseEnter={preloadProductPage}
onFocus={preloadProductPage}
>
<ProductImage image={product.featuredImage} />
<ProductTitle>{product.title}</ProductTitle>
<ProductPrice price={product.priceRange} />
</a>
);
}

Intersection Observer for Lazy Loading

Load components when they scroll into view:

src/hooks/useLazyComponent.ts
import { useState, useEffect, useRef, ComponentType } from 'react';
interface UseLazyComponentOptions {
rootMargin?: string;
threshold?: number;
}
export function useLazyComponent<P extends object>(
importFn: () => Promise<{ default: ComponentType<P> }>,
options: UseLazyComponentOptions = {}
) {
const [Component, setComponent] = useState<ComponentType<P> | null>(null);
const [isInView, setIsInView] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Observe when container enters viewport
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{
rootMargin: options.rootMargin || '200px',
threshold: options.threshold || 0,
}
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [options.rootMargin, options.threshold]);
// Load component when in view
useEffect(() => {
if (isInView && !Component) {
importFn().then((module) => {
setComponent(() => module.default);
});
}
}, [isInView, Component, importFn]);
return { Component, containerRef, isLoaded: !!Component };
}
// Usage in a page
function ProductPage({ product }) {
const {
Component: Reviews,
containerRef: reviewsRef,
isLoaded: reviewsLoaded,
} = useLazyComponent(() => import('@/components/product/ProductReviews'));
return (
<div>
<ProductMain product={product} />
{/* Container triggers load when scrolled into view */}
<div ref={reviewsRef} className="reviews-section">
{reviewsLoaded ? (
<Reviews productId={product.id} />
) : (
<ReviewsSkeleton />
)}
</div>
</div>
);
}

Splitting Third-Party Libraries

Large libraries should be split separately:

vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// React goes in its own chunk (shared across all pages)
'react-vendor': ['react', 'react-dom'],
// Animation library in separate chunk
'framer-motion': ['framer-motion'],
// State management
'zustand': ['zustand'],
// Date utilities (if used)
'date-fns': ['date-fns'],
},
},
},
},
});

For conditional library loading:

// Only load Framer Motion when animations are needed
async function loadAnimatedComponent() {
const [{ motion }, { AnimatedList }] = await Promise.all([
import('framer-motion'),
import('@/components/AnimatedList'),
]);
return { motion, AnimatedList };
}

Error Boundaries for Lazy Components

Handle chunk loading failures gracefully:

src/components/ErrorBoundary/ChunkErrorBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ChunkErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error) {
// Check if it's a chunk loading error
if (error.message.includes('Loading chunk') ||
error.message.includes('Failed to fetch')) {
console.error('Chunk loading failed:', error);
// Could report to analytics here
}
}
handleRetry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="chunk-error">
<p>Failed to load this section.</p>
<button onClick={this.handleRetry}>Try Again</button>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</div>
);
}
return this.props.children;
}
}

Wrap lazy components:

<ChunkErrorBoundary fallback={<CartDrawerFallback />}>
<Suspense fallback={<LoadingSpinner />}>
{isCartOpen && <CartDrawer />}
</Suspense>
</ChunkErrorBoundary>

Measuring the Impact

Track chunk sizes and loading performance:

src/lib/performance.ts
export function trackChunkLoad(chunkName: string) {
const startTime = performance.now();
return {
complete() {
const duration = performance.now() - startTime;
// Log in development
if (process.env.NODE_ENV === 'development') {
console.log(`[Chunk] ${chunkName} loaded in ${duration.toFixed(0)}ms`);
}
// Report to analytics in production
if (typeof gtag !== 'undefined') {
gtag('event', 'chunk_load', {
chunk_name: chunkName,
load_time: Math.round(duration),
});
}
},
};
}
// Usage
const tracker = trackChunkLoad('cart-drawer');
import('@/components/cart/CartDrawer').then((module) => {
tracker.complete();
return module;
});

Key Takeaways

  1. Split by page type first: Product, collection, cart, and account pages should load different bundles.

  2. Lazy load overlays: Cart drawers, modals, and search overlays are perfect candidates—they’re not needed on initial load.

  3. Preload on intent: Start loading chunks on hover/focus before the user clicks.

  4. Use Intersection Observer: Load below-the-fold components when they scroll into view.

  5. Split heavy libraries: Framer Motion, date libraries, and other large dependencies should be in separate chunks.

  6. Handle errors gracefully: Chunk loading can fail on slow connections. Provide retry options.

  7. Measure impact: Track chunk sizes and loading times to verify improvements.

  8. Keep critical path lean: The code needed for initial render should be as small as possible.

In the next lesson, we’ll dive into bundle analysis to identify exactly what’s in your bundles and where to focus optimization efforts.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...