Performance Fundamentals: Critical Rendering
Optimize your Shopify theme's load speed by understanding the critical rendering path, implementing lazy loading, and improving Core Web Vitals scores.
Fast themes convert better. A one-second delay in load time can reduce conversions by 7%. Understanding performance fundamentals helps you build themes that load quickly and keep customers engaged.
The Critical Rendering Path
The browser must complete these steps before displaying content:
- Parse HTML into DOM (Document Object Model)
- Parse CSS into CSSOM (CSS Object Model)
- Combine DOM + CSSOM into Render Tree
- Layout: Calculate element positions
- Paint: Draw pixels to screen
Anything that blocks these steps delays your page.
Render-Blocking Resources
CSS Blocks Rendering
The browser won’t paint until all CSS is loaded and parsed:
<!-- Blocking: Browser waits for this --><link rel="stylesheet" href="theme.css" />
<!-- Content won't appear until CSS loads --><body> ...</body>Optimization: Keep critical CSS small and inline it.
JavaScript Can Block
Scripts without defer or async block HTML parsing:
<!-- Blocking: Stops HTML parsing --><script src="analytics.js"></script>
<!-- Non-blocking --><script src="theme.js" defer></script>Critical CSS Inlining
Inline CSS needed for above-the-fold content:
<head> <style> /* Critical CSS: Header, hero, basic layout */ body { margin: 0; font-family: var(--font-body); }
.header { display: flex; align-items: center; padding: 1rem; }
.hero { min-height: 50vh; display: flex; align-items: center; } </style>
<!-- Non-critical CSS loads after --> <link rel="preload" href="{{ 'theme.css' | asset_url }}" as="style" onload="this.onload=null;this.rel='stylesheet'" > <noscript> <link rel="stylesheet" href="{{ 'theme.css' | asset_url }}"> </noscript></head>Core Web Vitals
Google measures three key metrics:
Largest Contentful Paint (LCP)
Time until the largest visible element renders. Target: under 2.5 seconds.
Common culprits:
- Large hero images without proper loading
- Web fonts blocking text rendering
- Slow server response
Fixes:
{# Preload LCP image #}<link rel="preload" href="{{ section.settings.hero_image | image_url: width: 1200 }}" as="image" fetchpriority="high">
{# Use fetchpriority for hero #}<img src="{{ section.settings.hero_image | image_url: width: 1200 }}" fetchpriority="high" alt="Hero banner">First Input Delay (FID) / Interaction to Next Paint (INP)
Time from user interaction to browser response. Target: under 100ms (FID) or 200ms (INP).
Common culprits:
- Heavy JavaScript execution
- Long tasks blocking main thread
- Too many event listeners
Fixes:
// Break up long tasksfunction processLargeArray(items) { const chunk = items.splice(0, 100);
// Process chunk chunk.forEach(processItem);
// Yield to browser, then continue if (items.length) { requestIdleCallback(() => processLargeArray(items)); }}Cumulative Layout Shift (CLS)
Visual stability. Target: under 0.1.
Common culprits:
- Images without dimensions
- Ads or embeds loading late
- Fonts causing text reflow
Fixes:
{# Always set dimensions or aspect-ratio #}<img src="{{ image | image_url: width: 600 }}" width="{{ image.width }}" height="{{ image.height }}" alt="{{ image.alt }}" loading="lazy">
{# Or use aspect-ratio in CSS #}<style> .product-image { aspect-ratio: 3/4; width: 100%; }</style>Lazy Loading Images
Only load images when they’re about to enter the viewport:
Native Lazy Loading
{# Lazy load below-the-fold images #}<img src="{{ product.featured_image | image_url: width: 400 }}" loading="lazy" alt="{{ product.title }}">
{# Don't lazy load above-the-fold images #}<img src="{{ section.settings.hero | image_url: width: 1920 }}" loading="eager" fetchpriority="high" alt="Hero">Responsive Images with srcset
<img src="{{ image | image_url: width: 600 }}" srcset=" {{ image | image_url: width: 300 }} 300w, {{ image | image_url: width: 600 }} 600w, {{ image | image_url: width: 900 }} 900w, {{ image | image_url: width: 1200 }} 1200w " sizes="(min-width: 1024px) 25vw, (min-width: 768px) 50vw, 100vw" loading="lazy" alt="{{ image.alt }}">Lazy Loading Background Images
For CSS backgrounds, use Intersection Observer:
const lazyBackgrounds = document.querySelectorAll('[data-lazy-bg]');
const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const el = entry.target; el.style.backgroundImage = `url('${el.dataset.lazyBg}')`; observer.unobserve(el); } }); }, { rootMargin: '100px' });
lazyBackgrounds.forEach((el) => observer.observe(el));<div class="banner" data-lazy-bg="{{ section.settings.image | image_url: width: 1920 }}">JavaScript Optimization
Defer Non-Critical Scripts
{# At end of body #}<script src="{{ 'vendor.js' | asset_url }}" defer></script><script src="{{ 'theme.js' | asset_url }}" defer></script>
{# Third-party scripts async #}<script src="https://example.com/widget.js" async></script>Code Splitting
Load code only when needed:
// Load cart drawer code only when openingdocument.querySelector('.cart-toggle').addEventListener('click', async () => { const { initCartDrawer } = await import('./cart-drawer.js'); initCartDrawer();});Avoid Layout Thrashing
Batch reads and writes:
// BAD: Read, write, read, write (forces multiple layouts)elements.forEach((el) => { const height = el.offsetHeight; el.style.height = height + 10 + 'px';});
// GOOD: Read all, then write allconst heights = elements.map((el) => el.offsetHeight);elements.forEach((el, i) => { el.style.height = heights[i] + 10 + 'px';});Font Optimization
Font Display Swap
Render text immediately with fallback, swap when font loads:
{% style %} {{ settings.type_body_font | font_face: font_display: 'swap' }} {{ settings.type_header_font | font_face: font_display: 'swap' }}{% endstyle %}Preload Key Fonts
<link rel="preload" href="{{ settings.type_header_font | font_url }}" as="font" type="font/woff2" crossorigin>Limit Font Weights and Styles
Each weight/style is a separate file:
{# Only load what you need #}{%- liquid assign body_font = settings.type_body_font assign body_font_bold = body_font | font_modify: 'weight', 'bold'-%}
{% style %} {{ body_font | font_face: font_display: 'swap' }} {{ body_font_bold | font_face: font_display: 'swap' }}{% endstyle %}Image Optimization
Use Modern Formats
Shopify CDN serves WebP and AVIF automatically when supported:
{# CDN automatically serves best format #}{{ image | image_url: width: 600 }}Right-Size Images
Never load larger than needed:
{# Match image size to container #}<img src="{{ product.featured_image | image_url: width: 400 }}" width="200" height="267" alt="{{ product.title }}">Use Placeholder Colors
Show a color placeholder while image loads:
<div class="image-wrapper" style="background-color: {{ image.dominant_color | default: '#f0f0f0' }};"> <img src="{{ image | image_url: width: 600 }}" loading="lazy" alt="{{ image.alt }}" ></div>.image-wrapper { aspect-ratio: 3/4;}
.image-wrapper img { width: 100%; height: 100%; object-fit: cover;}Shopify Theme Inspector
Shopify provides a browser extension for analyzing Liquid performance:
- Install “Shopify Theme Inspector for Chrome”
- Open your store
- Open DevTools and find the “Shopify” tab
- See which Liquid code is slowest
Common Liquid Performance Issues
{# SLOW: Nested loops #}{%- for product in collection.products -%} {%- for variant in product.variants -%} {# This runs collection.products.size * avg_variants times #} {%- endfor -%}{%- endfor -%}
{# BETTER: Limit iterations #}{%- for product in collection.products limit: 12 -%} {{ product.title }}{%- endfor -%}{# SLOW: Many section.settings calls #}<div style=" background: {{ section.settings.bg }}; color: {{ section.settings.text }}; padding: {{ section.settings.padding }}px;">
{# BETTER: Assign once #}{%- liquid assign bg = section.settings.bg assign text = section.settings.text assign padding = section.settings.padding-%}
<div style="background: {{ bg }}; color: {{ text }}; padding: {{ padding }}px;">Complete Performance Checklist
HTML/Liquid
- Inline critical CSS
- Defer non-critical CSS
- Use
deferon scripts - Limit Liquid loops
- Avoid nested loops where possible
Images
- Add
loading="lazy"to below-fold images - Set
fetchpriority="high"on LCP image - Include width and height attributes
- Use appropriate srcset and sizes
- Preload hero image
CSS
- Minimize CSS file size
- Avoid render-blocking CSS
- Use
font-display: swap - Preload key fonts
JavaScript
- Load scripts at end of body
- Use defer for critical scripts
- Use async for independent scripts
- Consider code splitting
- Minimize main thread work
Third-Party
- Load third-party scripts async
- Consider facade patterns for heavy embeds
- Delay non-essential tracking until interaction
Practice: Optimize a Hero Section
Before:
<link rel="stylesheet" href="{{ 'hero.css' | asset_url }}"><script src="{{ 'hero.js' | asset_url }}"></script>
<section class="hero"> <img src="{{ section.settings.image | image_url: width: 1920 }}"> <h1>{{ section.settings.heading }}</h1></section>After:
{# Preload hero image #}<link rel="preload" href="{{ section.settings.image | image_url: width: 1200 }}" as="image" imagesrcset=" {{ section.settings.image | image_url: width: 600 }} 600w, {{ section.settings.image | image_url: width: 1200 }} 1200w, {{ section.settings.image | image_url: width: 1920 }} 1920w ">
<section class="hero"> <img src="{{ section.settings.image | image_url: width: 1200 }}" srcset=" {{ section.settings.image | image_url: width: 600 }} 600w, {{ section.settings.image | image_url: width: 1200 }} 1200w, {{ section.settings.image | image_url: width: 1920 }} 1920w " sizes="100vw" width="{{ section.settings.image.width }}" height="{{ section.settings.image.height }}" fetchpriority="high" alt="{{ section.settings.image.alt }}" > <h1>{{ section.settings.heading }}</h1></section>
{# Load CSS inline or preload #}<style> .hero { position: relative; } .hero img { width: 100%; height: auto; } .hero h1 { position: absolute; }</style>
{# Defer JS #}<script src="{{ 'hero.js' | asset_url }}" defer></script>Key Takeaways
- Understand the critical path: DOM + CSSOM = Render
- Inline critical CSS for fastest first paint
- Defer scripts to avoid blocking HTML parsing
- Optimize LCP with preloading and fetchpriority
- Reduce CLS by setting image dimensions
- Lazy load below-the-fold images
- Use
font-display: swapfor text visibility - Measure with DevTools and Theme Inspector
- Target Core Web Vitals thresholds
Module Complete!
Congratulations on completing Module 6! You now understand how to load assets efficiently, structure CSS for maintainability, build responsive layouts, write progressive JavaScript, leverage Shopify’s utilities, and optimize performance.
Next up: Module 7: Global UI: Header and Navigation where you’ll build the core navigational elements of your theme.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...