A smaller initial JavaScript bundle is not necessarily a faster application. If an essential component has to wait for another request before it can render, or the first click starts downloading a large library, splitting can move the wait instead of removing it.
Code splitting helps when it keeps JavaScript that is not needed yet out of the critical loading path. It is a poor trade when it delays something the user needs immediately. The right boundary depends on the user journey, the generated chunk graph, and the network and main-thread work you can observe.
This guide builds a small on-demand feature, explains where framework defaults already help, and gives you a measurement procedure for deciding whether to keep the change. The example is a standalone demonstration, not a claimed production benchmark.
Decide which code has to arrive first
Code splitting changes when a browser requests and evaluates code. It does not remove functionality or guarantee fewer total bytes over a session. A bundler may extract shared dependencies into additional chunks, and a user who opens every feature may eventually download much of the same code.
Think in terms of a specific initial visit and subsequent action:
| Candidate | Initial-route requirement | Likely decision | Main risk |
|---|---|---|---|
| Navigation and essential controls | Immediately usable | Keep available at startup | Delayed interaction or hydration |
| Large editor opened from an optional button | Only after explicit intent | Consider a lazy boundary | The first open waits for a chunk |
| Chart visible in the initial viewport | Needed for the first view | Usually load with the route | Late chart or layout shift |
| Admin-only page in a multi-route app | Not needed on public pages | Prefer route-level splitting | Shared dependencies may remain eager |
| Small helper used by almost every screen | Needed frequently | Usually keep it in the normal graph | Extra request and chunk overhead |
This is a starting hypothesis, not a byte threshold. Before changing imports, check whether the framework already splits the route and whether another eagerly loaded module imports the same dependency. If it does, moving one import behind import() may not remove that code from the initial path.
It also helps to separate three techniques:
- Tree shaking can remove unreachable or unused exports during bundling.
- Code splitting produces separate output chunks that can be requested at different times.
- Lazy loading is the policy that waits until some condition, such as navigation or an interaction, before loading a resource.
A dynamic import() commonly enables both splitting and on-demand module loading in bundler-based applications, but the actual output is determined by the build tool. MDN's dynamic import reference explains the language-level behavior.
A small feature that can wait
Consider a report preview available behind a button. Visitors can read and navigate the page without the report formatter. That makes it a plausible split boundary.
For this illustrative browser example, place the following files in the same directory and serve them over HTTP. These are native ECMAScript modules; for a bundler-built app, inspect the production output before assuming the exact same network requests.
<button type="button" id="make-report">Prepare report</button>
<pre id="report-output" role="status" aria-live="polite"></pre>
<script type="module" src="./main.mjs"></script>
The entry module registers the handler without statically importing the formatter:
// main.mjs
const button = document.querySelector('#make-report');
const output = document.querySelector('#report-output');
button.addEventListener('click', async () => {
button.disabled = true;
output.textContent = 'Preparing report...';
try {
const { formatReport } = await import('./format-report.mjs');
output.textContent = formatReport([
{ label: 'Open', count: 12 },
{ label: 'Closed', count: 7 },
]);
} catch {
output.textContent = 'Report unavailable. Check your connection or reload the page.';
} finally {
button.disabled = false;
}
});
// format-report.mjs
export function formatReport(rows) {
return rows.map((row) => row.label + ': ' + row.count).join('\n');
}
The source's static dependency path no longer includes the formatter. In a browser, the first call to import() can initiate a request for it. A bundler such as Vite can turn that boundary into a separate output chunk. The user sees a loading status instead of an unresponsive control, and the button is re-enabled if loading fails.
The error path matters. A deferred module can fail because a network request fails, a deployment replaces a previously referenced chunk, or module evaluation throws. Catching the rejection keeps the control from getting stuck, but re-enabling the button does not guarantee a successful same-page retry. MDN documents that module evaluation failures are cached for that module, while load/link failures may be retried; browser fetch caching and deployment behavior can affect what happens next. If a deployment removed a previously referenced chunk, reloading the page may obtain the new entry bundle, whereas repeatedly clicking can keep failing. The message therefore suggests checking the connection or reloading instead of promising that another click will fix it. For longer operations, consider cancellation and a version-aware recovery strategy; this tiny example has neither.
The example also makes a tradeoff visible: the first report click can be slower even if startup becomes lighter. The report formatter is deliberately small, so splitting this particular function is not a recommendation for a real product. Its purpose is to expose the timing and failure behavior without inventing large-library savings.
React and Next.js: use the boundary the framework understands
For a React application with a rarely opened panel, React.lazy loads component code when React first tries to render it. Declare the lazy component at module scope, and put a Suspense boundary where a loading state is acceptable:
import { lazy, Suspense, useState } from 'react';
const ReportPanel = lazy(() => import('./ReportPanel.jsx'));
export default function ReportsPage() {
const [showReport, setShowReport] = useState(false);
return (
<section>
<h2>Reports</h2>
<button type="button" onClick={() => setShowReport(true)}>
Open report
</button>
{showReport && (
<Suspense fallback={<p role="status">Loading report…</p>}>
<ReportPanel />
</Suspense>
)}
</section>
);
}
This assumes ./ReportPanel.jsx has a default React component export. A failed lazy import is handled by the nearest React error boundary, not by Suspense alone. Real applications should provide an error boundary and a recovery path. Also reserve space if revealing the panel would otherwise shift surrounding content. React documents both the module-level declaration requirement and the Suspense fallback behavior.
Do not copy this pattern onto every component. A component rendered on every initial visit is not automatically a useful lazy boundary. If the fallback immediately appears for essential content, the split may be introducing a new wait.
In the Next.js 15 App Router (the version used by this portfolio), first inspect the existing Server and Client Component boundary. Server Components do not ship their component implementation as client-side JavaScript, and Next.js handles route-based splitting. For an optional Client Component, the documented options include next/dynamic and React.lazy. The versioned Next.js 15 lazy-loading guide also warns that dynamically importing a Client Component directly from a Server Component does not currently provide automatic code splitting in that configuration. Put the lazy boundary in an appropriate Client Component instead of assuming that every dynamic() call removes initial client JavaScript.
With Vite, dynamic imports can produce asynchronous chunks and Vite optimizes loading of shared dependencies to reduce request waterfalls. The Vite features guide also describes CSS extraction for async chunks. These build behaviors are reasons to inspect the emitted assets, not to assume one source module equals one network file.
Verify the boundary rather than the bundle count
An effective review compares the same user journey before and after a change. A suggested sequence:
- Record a baseline production build. Note the framework and build version, target route, device class, network profile, cache state, and whether the feature is used. Do not rely on a hot-reloaded development server.
- Inspect startup requests. In Chrome DevTools Network, filter JavaScript requests and compare transferred bytes, uncompressed sizes, request initiators, and ordering. In Coverage, look for code that remains unused during the initial journey. Coverage is a diagnostic, not proof that all unexecuted code can be safely removed.
- Implement one boundary. Build again. Confirm the intended code is absent from startup resources and appears when the deferred action happens. Look for unexpected shared chunks, duplicate dependencies, or CSS arriving late.
- Trace the first interaction. With cache cleared and network/CPU conditions held constant, open the feature. Measure how long the fallback remains visible, whether the click feels delayed, and whether script evaluation creates long tasks.
- Repeat on a warm visit and under failure. Cached chunks change the cost. Simulate a failed chunk request and verify the UI recovers. Check layout stability and keyboard/screen-reader announcements of loading and error states.
For instrument selection, Chrome's Coverage guide describes used and unused bytes, while its Network reference distinguishes transferred from uncompressed resource sizes. For real responsiveness, consult the INP optimization guide; a faster load does not prove a better interaction.
A useful before/after record separates the initial visit from feature activation:
| Observation | Initial route | First feature activation |
|---|---|---|
| JavaScript transferred | Should decrease only if code truly leaves startup | May increase when deferred chunk is fetched |
| Main-thread script work | May decrease | May shift to the interaction |
| Visible readiness | Must not regress for essential content | Loading and errors must be understandable |
| Cached return visit | Depends on chunk reuse and invalidation | Often differs from a cold first click |
Do not turn those directional expectations into invented results. Record actual measurements for the target application, ideally over repeated runs and field data where available. For interpreting noisy lab scores instead of optimizing to a single number, see the Lighthouse score triage guide.
When not to split
There are several defensible reasons to reject a proposed boundary.
The deferred code is on the critical path. An initial route whose hero depends on a lazy-loaded widget can create another request dependency and delay meaningful rendering. The browser's broader loading and rendering path is explained in the browser rendering guide.
The chunk is tiny or constantly used. Extra requests, poorer compression on smaller files, and more complex caching can offset savings. Smaller output files are not a performance score by themselves. web.dev's code-splitting discussion notes the tradeoff between caching, compression, and network round trips.
The feature is likely to be needed moments later. Deferring a checkout step until the confirmation click can make the most important interaction feel slower. Consider earlier loading at a justified intent signal, but verify that prefetching does not compete with critical resources or waste bandwidth for people who never use the feature.
The framework already handles it. Route-based chunks, preloading, and shared dependency extraction may be enabled by default. Manually restructuring the bundler's chunk rules can make caching and loading behavior harder to reason about unless a build report shows a specific problem.
The real problem is expensive execution. Moving a CPU-heavy operation into another chunk does not make the computation cheaper. If the long task still occurs when the user clicks, investigate the work itself, task scheduling, or a suitable worker. web.dev's long-task guide explains why shifting work is not the same as reducing input delay.
The practical criterion is not "how many chunks did we generate?" It is whether the right code arrives at the right time without sacrificing the next interaction. Keep a split when the trace shows a lighter startup and an acceptable deferred journey. Revert or rethink it when the result is only a new loading state at a more painful moment.
