A landing page is the one page whose only job is to load fast for someone who has never visited before. On one React app I worked on, it was also the page shipping the most JavaScript, because it was built inside the same bundle as the logged-in product. Reducing it by 10x took no clever tricks. It took doing a few unglamorous things in the right order.
Measure what actually ships
Start with a bundle analyzer, not a guess. For a webpack build:
npx webpack-bundle-analyzer dist/stats.json
Look at three things:
- What the landing page route pulls in. Often it's everything, because there is one entry point.
- The largest dependencies. Charting libraries, rich-text editors, date libraries with every locale and icon packs imported whole are the usual suspects.
- Duplicates. Two versions of the same library, pulled in by different packages.
Write the starting size down. Every later change gets measured against it.
Give the landing page its own chunk
The biggest single win is usually route-based code splitting. If the landing page and the dashboard share a chunk, every visitor downloads the dashboard.
import { lazy, Suspense } from 'react'
const Dashboard = lazy(() => import('./routes/Dashboard'))
const Settings = lazy(() => import('./routes/Settings'))
export function AppRoutes() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/app" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
)
}
Landing is imported normally, so it's in the first chunk. Everything behind a login moves out of it.
Check what the shared layout drags in
After splitting routes, look at what the landing page still imports through shared code: a top-level provider, a layout component, a global store. In our case, state management set up for the whole app was imported at the root, along with everything it referenced. Moving providers down to the routes that need them removed a large share of what was left.
Defer what's below the fold
The landing page itself had components a visitor doesn't see on load: a testimonial carousel, an interactive demo, a pricing calculator. Those can load when they scroll into view:
const PricingCalculator = lazy(() => import('./PricingCalculator'))
function PricingSection() {
const [ref, visible] = useInView({ rootMargin: '200px' })
return (
<section ref={ref}>
{visible && (
<Suspense fallback={<CalculatorSkeleton />}>
<PricingCalculator />
</Suspense>
)}
</section>
)
}
Replace heavy dependencies
For each large dependency left in the landing chunk, ask whether the page needs it at all:
- A date library for one formatted date can be replaced with
Intl.DateTimeFormat. - An icon pack imported as a whole can be swapped for per-icon imports.
- A utility library imported whole can become per-function imports or a few lines of code.
Keep it from growing back
A bundle shrinks once and grows forever unless something stops it. Add a size budget to CI so a pull request that grows the landing chunk past the limit fails:
{
"path": "dist/landing.*.js",
"limit": "120 KB"
}
Tools like size-limit or bundler performance budgets work the same way. The budget turns "someone should keep an eye on this" into a check that runs on every change.
The order matters
Splitting routes and moving providers did most of the work. Deferring below-the-fold sections and swapping dependencies finished it. The CI budget is what kept the result. If you only have an afternoon, measure first and split routes second. Those two steps usually find most of the savings.
