Code Splitting & Lazy Loading
2026-08-08 · 6 min read
The default behavior of a JavaScript bundler is to produce one file containing all of your application's code. Every route, every component, every feature — shipped upfront on the first page load, most of it unused.
This inflates bundle size, delays first render, and wastes bytes on code users may never interact with. A user landing on a news feed doesn't need the settings page, the composer, the reaction picker, or the 47 other post type renderers that might appear on a different visit.
Code splitting is the practice of breaking that bundle into smaller chunks and loading them only when needed.
Facebook's three-tier model
Facebook structures their loading priority around three tiers, each targeting a distinct milestone:
| Tier | What | When |
|---|---|---|
| 1 | App shell, skeleton screens, critical CSS | First visual response |
| 2 | Above-the-fold content renderers, basic action buttons | First interactive |
| 3 | Reaction pickers, hover cards, menus, composer extras, uncommon post renderers | On user intent or idle |
Tier 1 gets the user something on screen immediately. Tier 2 makes it usable. Tier 3 loads everything else — the long tail of features that most users won't touch on a given visit.
The key insight is that "everything" doesn't mean "everything upfront." It means "everything, eventually, on demand."
Splitting strategies
Route-level chunks
The simplest and highest-leverage split: each route is its own chunk. Secondary routes — profile, settings, notifications, search — are never downloaded until the user navigates there.
// React Router + React.lazy
import { lazy, Suspense } from 'react';
const SettingsPage = lazy(() => import('./pages/Settings'));
const ProfilePage = lazy(() => import('./pages/Profile'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Routes>
</Suspense>
);
}
React.lazy wraps a dynamic import(). The chunk is fetched only when the component first renders. Suspense shows the fallback while the chunk is in flight.
Next.js does this automatically per page. For finer control within a page, next/dynamic is the equivalent:
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
});
Data-driven renderer chunks
Route splitting helps with navigation but doesn't solve the problem of heterogeneous content. A news feed can contain plain text posts, video posts, poll posts, shared articles, sponsored content, and dozens of other types. Shipping all renderers upfront means sending code for post types the user will never see on this visit.
The solution is to let the server declare which renderer each item needs, and have the client load only the matched module.
Relay's @match / @module directives formalize this pattern: the server returns a __module field alongside each piece of content, naming the JS module that handles it. The client fetches and renders only the matched renderer.
fragment FeedItem on Post {
... on TextPost @module(name: "TextPostRenderer.react") { ...TextPostFragment }
... on VideoPost @module(name: "VideoPostRenderer.react") { ...VideoPostFragment }
... on PollPost @module(name: "PollPostRenderer.react") { ...PollPostFragment }
}
Without this, a feed with 50 possible post types requires shipping 50 renderers. With data-driven splitting, the client downloads exactly the renderers present in the current response.
The same concept applies outside Relay: a backend that returns { type: "video", rendererChunk: "VideoPostRenderer" } alongside the content data gives the client everything it needs to dynamically import the right module.
Interaction-triggered chunks
Tier 3 features — reaction pickers, hover cards, dropdown menus, modal dialogs — don't need to load until the user signals intent. Load them on pointerenter or the first click:
const reactionPickerRef = useRef(null);
async function handlePointerEnter() {
const { ReactionPicker } = await import('./ReactionPicker');
reactionPickerRef.current = ReactionPicker;
setPickerReady(true);
}
return (
<div onPointerEnter={handlePointerEnter}>
<LikeButton />
{pickerReady && <reactionPickerRef.current />}
</div>
);
pointerenter fires before click, giving the browser a small head start on fetching the chunk before the user actually clicks. For touch devices where pointerenter isn't reliable, trigger on touchstart or the first tap.
Idle and intent prefetch
Interaction-triggered loading avoids the upfront cost but introduces latency at the moment of interaction — the chunk has to download, parse, and execute before the feature is available. The fix is to prefetch likely chunks during idle time, so they're in the cache when needed.
requestIdleCallback runs work when the browser has nothing better to do — after first render, between frames:
requestIdleCallback(() => {
import('./ReactionPicker'); // prefetch during idle, not during startup
import('./CommentComposer');
});
pointerdown prefetch fires when the user presses down but before they release (click). Even a 100ms head start is often enough to hide chunk load time:
function LazyButton({ chunkPath, onClick }) {
const prefetch = () => import(chunkPath);
return (
<button onPointerDown={prefetch} onClick={onClick}>
Open
</button>
);
}
<link rel="prefetch"> lets the browser fetch chunks at low priority in the background, stored in the HTTP cache for instant use later:
<link rel="prefetch" href="/chunks/ReactionPicker.js" as="script" />
Unlike <link rel="preload"> — which fetches immediately at high priority for resources needed on the current page — prefetch is a low-priority hint for resources needed on future interactions or navigations.
The LCP vs INP tradeoff
Code splitting directly improves LCP (Largest Contentful Paint): less JavaScript in the initial bundle means the parser finishes sooner, the main thread is free sooner, and the first meaningful render happens earlier.
But it can hurt INP (Interaction to Next Paint): if the user's first interaction triggers a lazy-loaded chunk, the response is delayed by the chunk's download, parse, and execution time. On a slow connection, that can be hundreds of milliseconds — a janky experience even though the page loaded fast.
Without prefetch:
User clicks reaction button
→ chunk download (150ms) → parse (30ms) → execute (20ms) → picker opens
Total delay: 200ms+
With idle prefetch:
[idle time] → chunk downloaded and cached
User clicks reaction button
→ chunk from cache (0ms) → already parsed → picker opens instantly
The resolution is to split aggressively but prefetch the common interaction paths during idle time. The user pays nothing upfront and nothing at interaction time — the cost is amortized into idle periods where the browser has spare capacity.
What to prefetch is a product judgment: reactions and comments are near-universal; the composer extras and hover cards are worth prefetching; obscure settings panels are not.