How We Cut a Shopify Store's LCP From 4.8s to 1.9s in 72 Hours (Full Teardown)
A real anonymised Shopify performance case study. The audit, the four fixes that did 80 percent of the work, the measurements between each change, and a frank note on what we refused to do.
The number that matters is 4.8 to 1.9. That is the p75 mobile Largest Contentful Paint of a mid-size Shopify store on day zero compared to day three. No headless migration, no theme swap, no replatform, no speed booster app, no retainer. One senior engineer, seventy two hours, and four changes in the Liquid layer that did roughly eighty percent of the work. What follows is the complete teardown of that engagement, anonymised at the client's request but otherwise faithful to what the audit found, what we changed, and what each change moved the needle by. If you run a Shopify store stuck somewhere in the 3 to 5 second range on Core Web Vitals, the shape of your problem almost certainly looks like theirs.
The Store on Day Zero
The client sells mid-ticket home and garden goods, does roughly 250,000 monthly sessions, and runs a Dawn-derived custom theme that has accumulated three years of merchandising logic, fourteen installed apps, and the usual archaeology of handoffs between three previous agencies. Field data from the Chrome UX Report put p75 mobile LCP at 4.8 seconds, FCP at 2.1, and CLS at 0.12. INP was fine at 190 milliseconds, which is often a sign that the JavaScript tax is tolerable and the real problem is in the critical rendering path. TTFB from the Shopify edge was 0.9 seconds, high but not the root cause. When TTFB is under a second and LCP is nearly five, the LCP element is almost always being starved, not the server.
We started where you always start, which is loading the homepage in a cold, throttled Chrome profile emulating a mid-tier Android on a Fast 3G connection, and watching the waterfall. The hero image fired at 3.2 seconds of wall time. It was 612 kilobytes, served from the Shopify CDN at 2000 pixels wide, rendered into a 640 pixel viewport. It had loading equals lazy on it. There were fourteen render-blocking requests in the head, two of which were app CSS files loaded synchronously, and one was a webfont CSS file from a third-party origin the theme had never preconnected. The preload hints that did exist in the head were for a slider script that only fires below the fold. Nothing preloaded the hero. At this point the diagnosis writes itself, but the fix is where stores usually go wrong, because the instinct is to reach for a plugin or a rewrite.
Why We Did Not Go Headless and Did Not Swap the Theme
Before writing a single line of Liquid we took the contrarian decision off the table. A Hydrogen migration would have solved the LCP problem, eventually. It would also have taken six to ten weeks, required rebuilding every section the merchandising team depends on, broken half the installed apps, and frozen CRO testing during the transition. For a store doing 250,000 sessions a month, every week of disruption is measurable revenue. The same logic ruled out a theme swap. Moving to a clean copy of Dawn 15 would have deleted three years of conversion-tuned layout decisions and reintroduced the exact app conflicts the existing theme had already absorbed. We have seen both moves fail loudly in the last year, and we have seen the simpler path succeed quietly. When LCP is pinned by a lazy hero, an oversized CDN image, render-blocking app CSS, and a missing font preload, the fix is four files, not four thousand.
The other thing we refused was installing a third-party speed optimisation app. Those apps work by injecting their own JavaScript to rewrite image tags, defer scripts, and inline CSS. They add a payload, they mask the real issues, and the moment you uninstall them the store regresses. Every one we have audited has eventually become the bottleneck it was sold to solve.
Day One, Fix One, The Hero Image
The first change took ninety minutes and moved LCP from 4.8 to 3.4 seconds. There were three independent bugs bundled into the hero image, and it is worth pulling them apart because any one of them will alone cost you a full second.
The first was the loading equals lazy attribute on the hero img. The theme did not write it. A third-party wishlist app, installed eighteen months earlier, used the Shopify ScriptTag API to inject a runtime IntersectionObserver that rewrote every image tag on the page to lazy, presumably because the app vendor wanted to avoid blame for performance regressions from their own widget. You cannot find this bug by reading the theme source. You have to open DevTools on the live store, inspect the rendered hero img element, and compare it against the Liquid output in View Source. The source says eager, the DOM says lazy. We contacted the app vendor, confirmed the behaviour was configurable from a hidden admin flag, and disabled it. If yours is not configurable, the patch is to add a small inline script in theme.liquid that runs before the app's script tag and pins the loading attribute of the hero image, although the cleaner answer is to replace the app.
The second bug was that the hero lived inside the fourth section of the template. Shopify's newer image_tag filter, which the theme had been migrated to use, silently applies loading equals lazy to any image past section.index 3 unless you explicitly override it. This is documented but easy to miss. The fix was to pass loading: 'eager' and fetchpriority: 'high' explicitly in the filter call. The Liquid now reads, in effect, image_tag featured_image, loading: 'eager', fetchpriority: 'high', sizes: '100vw', widths: '375, 540, 750, 1080, 1280'. That last argument matters for fix two.
The third was the absence of a preload hint. We added one line to theme.liquid, positioned above the content_for_header output, not below it. Position matters. Shopify apps frequently inject their own preloads into content_for_header, and browsers honour fetch priority in roughly declaration order. If the hero preload is below the app preloads, the app preloads win the first connection slots and your LCP image queues behind a marketing pixel's script. The preload we added was a rel equals preload, as equals image, fetchpriority equals high link pointing at the Shopify CDN URL of the current template's featured image, rendered at 1080 width, with imagesrcset and imagesizes mirroring the responsive markup. The browser preloads the correct variant for the viewport instead of speculatively grabbing the wrong one.
After shipping these three sub-fixes as a single deploy, CrUX origin data had not updated yet, but our synthetic DebugBear run on the matched network profile dropped LCP from 4.8 to 3.4, a 1.4 second improvement from attributes and a link tag.
Day Two, Fix Two, The CDN Image Parameters
The next morning, the hero image was loading eagerly and with priority, but the payload was still 612 kilobytes. The Shopify CDN URL being requested ended in width equals 2000, format equals jpg, quality equals 85. Two of those three parameters were costing us time. The width equals 2000 was a historical setting from when the theme was built for a different hero layout, never revised when the current 640 pixel CSS width was introduced. The format equals jpg was actively counterproductive, because Shopify's image CDN automatically content-negotiates using the browser's Accept header and will serve AVIF to Chrome, WebP to Safari, and JPG as a fallback, but only if you do not specify a format. Pinning format equals jpg opts the browser out of the newer codecs it would otherwise accept, and those are twenty to forty percent smaller at equivalent quality.
We removed the format parameter entirely, dropped the width to 1280 as the upper bound of the srcset, and let the widths array render the Liquid image_url filter to produce five variants at 375, 540, 750, 1080, and 1280 pixels. The sizes attribute was set to 100vw because the hero is a full-bleed image. For a Pixel 7 at DPR 2.625 viewing a 412 CSS pixel viewport, the browser now picks the 1080 variant, which in AVIF weighs 84 kilobytes. The 2000-wide JPG was 612. That is a 528 kilobyte reduction on the single most important byte stream on the page. Shopify's CDN caches at the edge across two hundred plus PoPs, so the new URL warmed globally within a few minutes.
Synthetic LCP after the deploy was 2.7 seconds. We were now inside the 2.5 second Good threshold by a whisker on desktop, still a shade over on mobile, and two fixes in.
Day Two, Fix Three, The Font Strategy
The third fix was less visible but equally important. The theme loaded two font families from a third-party CDN, with an @import in a CSS file that was itself loaded synchronously in the head. The chain was render-blocking CSS, then a second CSS fetch, then the font files, then paint. The theme had no preconnect to the font origin and no preload for the primary font WOFF2. Font-display was set to block, which is the browser default when unset and is the reason text sometimes appears two seconds after the layout does, a separate but related CLS contributor.
We did three things. We added a preconnect hint for the font origin, one for fonts.gstatic.com and one with crossorigin for the font file host. We added a rel equals preload, as equals font, type equals font/woff2, crossorigin for the single weight the above-fold hero text actually uses, which was the 600 weight of the primary family. The other five weights can lazy-resolve via the existing CSS. We replaced font-display: block with font-display: swap in the @font-face rules, accepting a brief FOUT in exchange for paint progress. The hero text is now what the LCP algorithm measures on text-dominant pages and what users see regardless.
The net was another 400 milliseconds. LCP now sat at 2.3 seconds on the mobile synthetic profile. Good threshold crossed on both form factors.
Day Three, Fix Four, The Render-Blocking App CSS
The last meaningful change was deferring two app CSS files that had been loaded synchronously in the head. One belonged to a product reviews app, one to a currency converter. Together they added 43 kilobytes of CSS and about 300 milliseconds of blocking time on a cold cache. Neither widget renders above the fold on the homepage or on most product pages.
The conventional advice is to inline critical CSS and defer the rest. That is the right answer for themes being built from scratch. For a live store with three years of CSS accretion, the cheaper and safer pattern is the media-print swap, which loads the stylesheet with media equals print so the browser fetches it without blocking render, then flips it to media equals all in an onload handler. This is a one-line change per stylesheet and it is well supported. We applied it to both app CSS files and to one theme-level CSS module that contained below-the-fold section styles. The browser still fetched them, still applied them, but no longer waited on them before painting the hero.
Post-deploy, synthetic LCP was 1.9 seconds. Field CrUX data caught up over the following fourteen days and confirmed the move, with p75 mobile LCP stabilising at 2.0 seconds, a hair above the synthetic number as is usual. FCP dropped from 2.1 to 1.3. CLS dropped from 0.12 to 0.04 as a side effect of the font-display fix. INP was unchanged, which is what we wanted because we had not touched the JavaScript layer. Conversion rate on mobile lifted 6.3 percent over the next month, which is within the range the literature would predict for that LCP delta, though causation is always fuzzy in e-commerce.
What We Deliberately Left on the Table
There were three optimisations on the audit that we scoped out and documented for a later engagement. The first was replacing the product page image gallery library, which ships 180 kilobytes of JavaScript for a feature that could be done in 8 kilobytes of vanilla code. It matters for INP on product pages but not for homepage LCP. The second was consolidating the three analytics scripts the marketing team layers on top of each other. The third was migrating the theme from Dawn 7 to Dawn 15, which has better defaults but would have taken two weeks of QA the client did not want to spend during Q4. The point of a 72 hour engagement is to hit the biggest lever, cleanly, and leave a clean ticket trail for the rest.
The Pattern, Not the Story
Every Shopify store we have audited in the last year with p75 mobile LCP above 3.5 seconds has had some subset of these four problems. A hero image that is lazy, badly sized, or both. Shopify CDN URLs that specify format and over-request width. A font strategy that blocks paint or mis-preloads. Render-blocking app CSS in the head. The fix is rarely in the architecture and is almost always in the Liquid. You can spend six weeks going headless to solve a problem that a senior developer can fix in three days, and in our experience the three day path is also the one that survives the next app installation because you understand what you shipped.
If your Core Web Vitals dashboard looks like the day zero graph in this teardown, WitsCode runs a fixed-scope Shopify performance engagement that mirrors what you just read. One senior engineer, five to seven days, a full audit on day one, shipped fixes by the end of the week, and a written handoff that your team can maintain. No retainers, no speed booster app, no platform migration. We have done this for sixty-plus Shopify merchants and the shape of the work is consistent enough that we can quote it in an hour. Send us the URL and your current CrUX numbers and we will tell you, honestly, whether a week of work is the right answer or whether your problem is somewhere else.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
Shopify Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss ecom for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.

