Hugo (the static site generator running this blog) hits 0.4s LCP because everything that matters at first paint sits inline in the HTML. Shopify Liquid runs on a server, not at build time, but the same 3 tricks transfer cleanly to themes: inline critical CSS, prioritise the hero image with fetchpriority plus srcset, preload the LCP candidate. Below is the per-trick implementation.
TL;DR: Sub-1s LCP on a Liquid theme uses 3 borrowed-from-SSG patterns. Inline critical CSS for above-fold styles (~12KB per template). Apply fetchpriority="high" plus a tight srcset to the hero image via image_url. Add a <link rel="preload"> for the LCP image in the <head>. CrUX p75 on the stores where I have shipped this combo runs 0.8-1.2s mobile.
Why most Shopify themes start at 2.4-3.1s
A baseline Dawn install on a fast 4G mobile connection lands at p75 LCP around 2.4-3.1s. The dominant causes, in order:
- Render-blocking CSS loaded via
<link rel="stylesheet">in the head. The browser parses HTML, hits the link, blocks render until the CSS arrives, then paints. - Hero image discovered late. Without
fetchpriorityor preload, the LCP candidate competes with theme JS and app scripts in the browser fetch queue. - Font-display swap with a heavy custom font. Hero text either flashes invisible (FOIT) or shifts (FOUT), and the LCP candidate moves with it.
The 3 fixes below target these in order.
Trick 1: inline critical CSS, defer the rest
Hugo emits critical CSS inline in the HTML head and lazy-loads everything else after first paint. Shopify can do the same with a Liquid pattern:
{%- comment -%} layout/theme.liquid {%- endcomment -%}
<style>
{%- render 'critical-css' -%}
</style>
<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>
The critical-css.liquid snippet contains the inlined above-fold rules: header, hero, primary CTA, font-face declarations, layout grid. Target ~12KB uncompressed, which gzips to ~3-4KB and fits inside the initial TCP slow-start window so it arrives in the first round trip.
To generate it, run Lighthouse on a representative PDP and homepage in DevTools, extract the “critical request chain” CSS, then trim by hand. Or use PenthouseJS at build time to extract per-template. Commit the output as static Liquid snippets, not runtime-extracted; the runtime extraction overhead defeats the LCP gain.
One gotcha: the theme editor preview reads the rendered HTML to inject blocks. Keep the linked sheet loading (just deferred), so editor blocks below the fold still render correctly. Inline ≠ replace.
Trick 2: image_url + fetchpriority + a tight srcset
The hero image is almost always the LCP candidate on a Shopify storefront. Three things matter for it: discovery time, priority in the fetch queue, and resolution matched to the device.
Discovery: put the <img> tag early in the body, not nested 6 levels deep inside JS-hydrated sections. Priority: fetchpriority="high" as a static attribute. Resolution: srcset with 3-4 widths so a 360px-wide phone does not download a 2400px image.
{%- assign hero = section.settings.hero_image -%}
<img
src="{{ hero | image_url: width: 1200, format: 'webp' }}"
srcset="
{{ hero | image_url: width: 480, format: 'webp' }} 480w,
{{ hero | image_url: width: 800, format: 'webp' }} 800w,
{{ hero | image_url: width: 1200, format: 'webp' }} 1200w,
{{ hero | image_url: width: 1800, format: 'webp' }} 1800w
"
sizes="(max-width: 768px) 100vw, 1200px"
width="1200"
height="800"
alt="{{ hero.alt | escape }}"
fetchpriority="high"
decoding="async">
width and height attributes are non-negotiable because they reserve layout space and prevent CLS, which is a separate Core Web Vital but lives downstream of the same hero asset. decoding="async" tells the browser to decode off the main thread; pairs cleanly with fetchpriority="high" for fetch priority without main-thread cost.
Shopify CDN serves WebP automatically when supported, but specifying format: 'webp' ensures the URL hashes match for cache hits and avoids the content-negotiation overhead. On stores I have measured, the WebP variant is 35-55% smaller than the JPEG baseline.
For the broader image optimisation playbook including AVIF fallback patterns and the new responsive_image Liquid filter, see my Shopify Liquid image filters guide.
Trick 3: preload the LCP candidate in the head
Even with fetchpriority="high", the browser still has to discover the <img> tag during HTML parsing. On a mobile parser-blocking connection, that costs 200-400ms. Preload jumps the queue:
{%- if template == 'index' and section.settings.hero_image -%}
<link rel="preload"
as="image"
href="{{ section.settings.hero_image | image_url: width: 1200, format: 'webp' }}"
imagesrcset="
{{ section.settings.hero_image | image_url: width: 480, format: 'webp' }} 480w,
{{ section.settings.hero_image | image_url: width: 800, format: 'webp' }} 800w,
{{ section.settings.hero_image | image_url: width: 1200, format: 'webp' }} 1200w
"
imagesizes="(max-width: 768px) 100vw, 1200px"
fetchpriority="high">
{%- endif -%}
Wrap the snippet in a template guard so it only fires on templates where the hero exists. Otherwise the browser preloads an empty URL and wastes the bandwidth.
The imagesrcset and imagesizes attributes on preload let the browser pick the right resolution before the body <img> tag is parsed. Most theme implementations skip these and end up preloading the full-size 1800px image on a 360px phone, which adds 80-150ms and wastes the preload. The 4-line attribute change is the difference between a preload that helps and a preload that hurts.
Verifying the fix in DevTools
Three checks per template after deploying all 3 tricks.
Lab Lighthouse mobile. Run from DevTools, “Mobile” + “Slow 4G” + “4x CPU throttle”. The LCP metric should drop from baseline 2.4-3.1s to lab 0.9-1.4s. The first run after the deploy is the leading indicator.
Real-device test. Open the storefront on an iPhone 12 or mid-tier Android, hard-reload (Cmd+Shift+R or hold reload button). The hero should paint within the first 1.2s of network activity. If it does not, check that the preload URL in the network panel matches the actual <img> src (mismatch wastes the preload).
CrUX field data. Pull from PageSpeed Insights at 7 days, 14 days, 28 days post-deploy. The rolling window refreshes a fraction each day; day 28 is the first fully post-fix measurement. For the full CWV measurement methodology and a free tool to pull your store’s CrUX directly, see the Shopify CrUX Grader.
What this does not fix
Three patterns the 3 tricks above do not solve, in case you measure no improvement:
- JS-hydrated hero sections. If the hero image lives inside a JS framework component that hydrates client-side, none of the static HTML tricks apply. The image is rendered after JS executes. Fix: move the hero to server-rendered Liquid.
- CDN cold cache. First request to a new image URL is uncached at the Shopify CDN. Subsequent requests are cached. If your LCP measurement runs on a fresh image, the first sample is slower than steady state. Warm the cache by hitting the URL once before measuring.
- Custom font-display: swap with heavy variable font. The hero text shifts when the font swaps in, which can move the LCP candidate. Fix: subset the font to Latin Basic + numerals + punctuation (~28KB), self-host on Shopify CDN, preload alongside the hero image. Full detail in my Shopify font loading guide.
For the full Core Web Vitals optimisation playbook covering LCP plus INP plus CLS together, see my Shopify Core Web Vitals 2026 guide.
The takeaway
- Inline critical CSS (~12KB) for above-fold styles. Defer the linked stylesheet with
media="print" onload="this.media='all'". First paint drops by 200-600ms. - Use image_url + fetchpriority + srcset on the hero
<img>. Static attribute, no JS. WebP saves 35-55% over JPEG on the same URL. - Preload the LCP image in the
<head>withimagesrcsetandimagesizesso the right resolution wins. Template-guard the preload so empty URLs do not waste bandwidth. - Verify in DevTools first, then CrUX over 28 days. Lab moves immediately; field is a rolling window. Day 14 is the read; day 28 is the proof.
- Combine with the third-party script defer pattern in my Shopify INP defer playbook so LCP and INP both move green in the same sprint.