Key Takeaways
- For a no-SSR React SPA embedded in Shopify Admin, Largest Contentful Paint is gated by how much JavaScript the browser must parse before it can render. Shrink that, and LCP drops.
- Route-level code splitting is the biggest win: ship a tiny entry bundle and load each page's code only on navigation.
- Do not shard assets onto a separate CDN domain. Under HTTP/2 and HTTP/3 that adds DNS and TLS cost and loses same-origin multiplexing, making LCP worse.
- Pull third-party scripts (chat, analytics) off the critical path with
requestIdleCallback, and let the edge cache your immutable assets.
Largest Contentful Paint is the Core Web Vital that measures when the main content appears, and Google rates it "good" at 2.5 seconds or less (web.dev, Largest Contentful Paint). For a server-rendered page, LCP is usually about images and HTML. For a client-rendered React SPA with no server rendering, embedded in an iframe inside Shopify Admin, LCP is almost entirely about JavaScript: nothing meaningful paints until the bundle downloads, parses, and runs.
This guide documents how we improved LCP for the Flicky admin frontend, and, just as importantly, the optimization we deliberately rejected. The headline is that the fix is to reduce the critical JavaScript path, and that a popular-sounding idea, moving assets to a dedicated CDN domain, would have hurt rather than helped on a modern protocol stack.
Why LCP Is a JavaScript Problem Here
The app is a React 19 SPA with no SSR. The initial HTML is a near-empty shell that mounts React into a root node. Until the JavaScript bundle arrives and executes, there is no content to be the "largest contentful paint," so LCP is bounded below by the time to download and run that bundle.
Before optimizing, the app had no code splitting at all: every page, including heavy detail forms, compiled into one large bundle that every merchant downloaded on first load, even to see the dashboard. That single bundle was the LCP bottleneck. The whole optimization strategy follows from this: get less JavaScript into the critical path.
The Rejected Approach: Domain Sharding
In this section you'll see why moving assets to a separate CDN domain is the wrong move, so you do not spend a sprint on it.
The tempting idea was to serve /assets/* from a dedicated CDN origin (an AWS CloudFront distribution over S3, say dxxxx.cloudfront.net). It sounds like a performance win, and it was, in the HTTP/1.1 era, when browsers capped connections per origin and spreading assets across domains unlocked parallelism.
Under HTTP/2 and HTTP/3 it is an anti-pattern:
- A separate origin means a new connection: another DNS lookup and another TLS handshake before the first asset byte arrives. That cost lands squarely in the critical path for LCP.
- You lose same-origin multiplexing. HTTP/2 and HTTP/3 send many requests over one connection to one origin; splitting assets to a second origin forfeits that and reintroduces connection overhead.
- The production domain is already proxied and edge-cached by Cloudflare, so
/assets/*is already served from a CDN on the same origin, with no extra connection.
So domain sharding would have added connection setup cost to shave nothing, and we rejected it. The right lever is less JavaScript, not more origins.
Step 1: Route-Level Code Splitting
In this step you'll split the single bundle into a tiny entry plus one lazy chunk per page, so first paint downloads far less.
Convert static page imports in the router to React.lazy behind a <Suspense> boundary:
// src/router.jsx
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const VolumeDiscountDetail = lazy(() => import('@/pages/VolumeDiscounts/VolumeDiscountDetail'));
const PricingRuleDetail = lazy(() => import('@/pages/PricingRules/PricingRuleDetail'));
// ...one lazy import per route
export const routes = (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/volume-discounts/:id" element={<VolumeDiscountDetail />} />
<Route path="/pricing/:id" element={<PricingRuleDetail />} />
</Routes>
</Suspense>
);What just happened: the entry bundle dropped to roughly 7.8 kB gzipped, and each page became its own chunk. The heavy detail forms (volume discount, pricing rule, and the like) now download only when a merchant actually navigates to them, instead of being parsed on the first dashboard paint. Since LCP here is bound by critical JavaScript, cutting the first-load bundle to a shell plus the landing route's chunk is the single largest improvement.
Step 2: Stable Vendor Chunks
In this step you'll split long-lived dependencies into their own chunks so they stay cached across deploys.
Group framework and data libraries into vendor chunks that rarely change, so a new app deploy does not force merchants to re-download React:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
// Split stable vendors into their own chunks → cached across deploys
// (only app chunks change hash). rolldown-vite requires manualChunks
// as a FUNCTION, not an object.
manualChunks(id) {
if (!id.includes('node_modules')) return undefined;
if (/[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/.test(id))
return 'react-vendor';
if (/[\\/]node_modules[\\/](@tanstack[\\/]react-query|axios|zustand)[\\/]/.test(id))
return 'data-vendor';
return undefined;
},
},
},
},
});What just happened: React and the router land in react-vendor; TanStack Query, Axios, and Zustand land in data-vendor. These change rarely, so their chunk hashes stay stable across app deploys and the browser keeps serving them from cache. Only your app code gets a new hash when you ship.
Watch out: this build uses
rolldown-vite(the Rolldown engine aliased as Vite), wheremanualChunksmust be a function. The Rollup-style object form ({ 'react-vendor': ['react', ...] }) throwsmanualChunks is not a function. If you copy a vendor-chunk snippet from a standard Vite guide, convert it to the function form.
Step 3: Defer Third-Party Scripts off the Critical Path
In this step you'll push non-essential third-party scripts past first paint so they do not compete for the main thread during LCP.
Chat widgets and analytics do not need to run before content appears. Load them during idle time, keeping a synchronous stub and queue so no early events are lost:
// Defer Crisp / Mixpanel to idle time; keep a sync stub so identify() calls queue.
function whenIdle(fn) {
if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 3000 });
else setTimeout(fn, 1);
}
whenIdle(() => {
// load Crisp chat, initialize Mixpanel, then flush any queued events
});What just happened: the chat and analytics scripts no longer compete with your app bundle for download bandwidth and main-thread time during the critical window. Keeping a lightweight synchronous stub and queue means an early identify() or event is buffered and flushed once the real library loads, so deferral costs you no data.
Step 4: Cache Assets at the Edge, on the Same Origin
In this step you'll set caching headers so returning visits skip the network for your hashed assets, without introducing a second origin.
# Immutable, content-hashed assets: cache hard. index.html: never cache.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}What just happened: content-hashed asset files are marked immutable so the browser (and Cloudflare's edge) never revalidate them, while index.html is no-cache so a deploy is picked up immediately. Because the production domain is already Cloudflare-proxied, these assets are served from the edge on the same origin, giving you CDN delivery without the extra-connection cost that domain sharding would have added.
Verification
- Measure LCP, not bundle size alone: record a cold load in the Chrome DevTools Performance panel and read the LCP marker; compare before and after code splitting on a throttled profile.
- Confirm the split: check the build output for a small entry chunk plus per-route chunks, and confirm detail-page chunks load only on navigation (watch the network tab).
- Confirm vendor caching: deploy a small app change and confirm
react-vendor/data-vendorchunk hashes are unchanged, so the browser serves them from cache. - Confirm deferral: verify chat and analytics requests fire after the
loadevent, during idle time, not before first paint. - Confirm single origin: check that assets load from your app domain, not a separate CDN host, so there is no extra DNS/TLS round trip.
Key Takeaways
1. In a no-SSR SPA, LCP is a JavaScript budget. Nothing paints until the bundle runs, so the fastest path to a better LCP is shipping less JavaScript on first load.
2. Route-level code splitting is the biggest lever. A tiny entry plus lazy per-route chunks means first paint no longer waits on code for pages the merchant has not opened.
3. Do not shard assets to another domain. Under HTTP/2 and HTTP/3 a second origin adds DNS and TLS cost and loses multiplexing. Same-origin edge caching gives you the CDN benefit without the penalty.
4. Get third parties off the critical path. Defer chat and analytics to idle time with a stub-and-queue so you keep the data without paying for it during LCP.
Frequently Asked Questions
Won't a dedicated CDN domain make assets load faster?
Not under HTTP/2 or HTTP/3. A separate origin forces a new DNS lookup and TLS handshake and forfeits same-origin multiplexing, which adds latency in the critical path. Domain sharding was an HTTP/1.1 workaround for per-origin connection limits that no longer exist. Serve assets from your own edge-cached origin instead.
How small should the entry bundle be?
Small enough that first paint is not waiting on code for pages the merchant has not visited. After route splitting, our entry dropped to roughly 7.8 kB gzipped, with each page in its own chunk. The exact number matters less than the principle: the landing route should not carry the whole app.
Does deferring analytics lose events?
No, if you keep a synchronous stub and queue. Early identify() or track calls are buffered against the stub and flushed when the real library loads during idle time. You move the script off the critical path without dropping data.
Why does manualChunks have to be a function here?
Because this build uses rolldown-vite, the Rolldown-engine alias of Vite, which accepts manualChunks only as a function (id) => chunkName. The Rollup-style object form valid in standard Vite throws a build error. Convert any object-form snippet to the function form.
Written by Duke, based on the production build of the Flicky embedded Shopify admin app. Code samples are drawn from the shipping configuration and simplified for readability. See also rendering widgets without layout shift and the engineering notes hub.