The previous two lessons went after the work React does while the user is using the app: renders that shouldn't happen, calculations redone over and over, identities that change for no reason. What's left is the other problem, the one that shows up before the user can use anything at all. Today, when someone opens CicloUrbano to check whether there's a bike free at Main Square, the browser downloads and runs the code for the workshop panel, the station detail view with its tabs, the booking form, and the occupancy chart library, before it paints the first card. All of that code is correct, well memoized, and isn't going to be used on that visit. No memoization technique fixes this, because the problem isn't how many times something runs: it's how much what gets downloaded weighs. The solution is called code splitting: breaking the bundle into chunks that download when they're needed, and in React it's expressed with two pieces — React.lazy and <Suspense> — built on top of a mechanism from JavaScript itself: the dynamic import(). In this lesson you'll learn to measure the bundle, to split it by route and by component, to prefetch so the wait goes unnoticed, to design the wait without visual jumps, and to survive when a chunk doesn't arrive.
Contents
- The problem with a single bundle
- Measuring before splitting: inspecting the bundle
- What code splitting is, and what chunks are
- Dynamic
import()as an automatic boundary React.lazyand<Suspense fallback>- Route-based splitting: the biggest payoff
- Why the catalogue isn't split
- Component-based splitting
- Prefetching: making the wait invisible
- Designing the wait: skeletons instead of "Loading…"
- When the chunk doesn't arrive
- Other size levers
Suspenseis much more than this
- The problem with a single bundle
When you run npm run build, Vite walks the import graph starting at main.jsx, resolves every static import, and bundles it all together. One file, or a handful. The browser has to download it whole, parse it whole, and execute it whole before React paints anything.
These are CicloUrbano's real dependencies after Module 7, with rough sizes for the app's own minified, gzip-compressed code:
| Part | Uncompressed | Compressed (gzip) |
|---|---|---|
react + react-dom |
~140 KB | ~45 KB |
react-router |
~70 KB | ~22 KB |
@reduxjs/toolkit + react-redux |
~90 KB | ~28 KB |
@tanstack/react-query |
~110 KB | ~35 KB |
| CicloUrbano's own code | ~180 KB | ~45 KB |
| Occupancy chart library | ~320 KB | ~95 KB |
| Total | ~910 KB | ~270 KB |
And now the uncomfortable part: of those 270 KB compressed, the entry route uses roughly half. The charts only show up on the station detail page, WorkshopPage is only seen by an operator, and NewBookingPage only opens when booking.
The cost isn't just the download. Parsing and executing JavaScript is CPU work, and on a mid-range phone it runs 3 to 5 times slower than on your laptop:
flowchart LR
A["Download<br/>270 KB over 4G<br/>~1.4 s"] --> B["Parse and compile<br/>910 KB uncompressed<br/>~0.6 s on mobile"]
B --> C["Execute<br/>modules + main.jsx<br/>~0.3 s"]
C --> D["First render<br/>+ API request"]
D --> E["Catalogue visible<br/>~3.5 s"]
Three and a half seconds until a single bike shows up, half of it spent on code that visit isn't going to use. That's the exact problem this lesson solves.
- Measuring before splitting: inspecting the bundle
Same as the rest of the module: measure first. Vite already gives you the first measurement without installing anything.
vite v6.0.5 building for production... ✓ 412 modules transformed. dist/index.html 0.48 kB │ gzip: 0.31 kB dist/assets/index-B4nQ8xJ2.css 18.24 kB │ gzip: 4.02 kB dist/assets/index-DvK2mR7p.js 908.77 kB │ gzip: 271.35 kB (!) Some chunks are larger than 500 kB after minification. Consider: - Using dynamic import() to code-split the application
That Rollup warning isn't decorative: it's exactly this lesson's advice. But Vite's output says how much it weighs, not what weighs it. For that you need a map of the contents.
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
filename: 'dist/stats.html',
gzipSize: true, // shows the compressed size, the one that matters
brotliSize: true,
open: false // set to true to open it automatically
})
]
});After npm run build, open dist/stats.html: a treemap where every rectangle is a module and its area is its weight. What to look for, in order:
| Signal on the map | What it means | Action |
|---|---|---|
| A huge rectangle for a library | A heavy dependency that maybe isn't always needed | Load it on demand (section 8) |
| All your code in a single block | There's no splitting at all | Split by route (section 6) |
| A library where you use one function | Non-selective import, or a library without tree-shaking | Review the import (section 12) |
Large .json data bundled in |
Data that should be fetched, not embedded | Dynamic import() or a request |
Full moment, full lodash, entire icon sets |
The usual suspects | Replace them or import selectively |
Write down the starting figure — 271 KB compressed — because section 6 compares it against the one after. Without that before-and-after, this isn't optimization.
- What code splitting is, and what chunks are
Code splitting means breaking the single bundle into several files, called chunks, so the browser only downloads the ones it needs for whatever the user is doing.
Splitting happens at two levels, and it's worth telling them apart:
- Automatic splitting by shared dependencies. Vite already separates
node_modulesinto a vendor chunk, so library code, which changes rarely, caches better across deploys. This happens on its own, and it doesn't affect how much gets downloaded on the first visit. - Splitting by load points, the one this lesson covers: you decide which parts of your application can wait, and mark them as such.
What's interesting is how you mark them: there's no configuration involved. The boundary between chunks is created by a syntax feature of JavaScript itself.
- Dynamic
import() as an automatic boundary
import() as an automatic boundary// Static import: resolved at build time, goes into the main bundle
import { generateReport } from './utils/reports.js';
// Dynamic import: returns a promise, and creates a NEW CHUNK
const { generateReport } = await import('./utils/reports.js');The essential differences:
Static import |
Dynamic import() |
|
|---|---|---|
| When it resolves | At build time | At runtime |
| What it returns | Bindings | A promise for the module object |
| Where it can appear | Only at the top level of a module | Anywhere: inside a function, inside an if |
| Effect on bundling | Folded into the current chunk | Creates a separate chunk |
| Can it take variables | No | Yes, with limits (Vite needs static hints in the path) |
And the effect on the module graph:
flowchart TD
subgraph BEFORE["A single chunk"]
M1["main.jsx"] --> R1["routes.jsx"]
R1 --> P1["CataloguePage"]
R1 --> P2["WorkshopPage"]
R1 --> P3["StationDetailPage"]
P3 --> G1["Occupancy chart library<br/>95 KB"]
R1 --> P4["NewBookingPage"]
M1 --> V1["react, router, redux, query"]
end
flowchart TD
subgraph AFTER["Initial chunk + on-demand chunks"]
M2["main.jsx"] --> R2["routes.jsx"]
R2 --> P5["CataloguePage<br/>entry route: static"]
M2 --> V2["react, router, redux, query"]
R2 -.->|"import() on navigation"| C1["chunk: WorkshopPage"]
R2 -.->|"import() on navigation"| C2["chunk: StationDetailPage"]
C2 -.->|"import() on opening tab"| C3["chunk: chart library 95 KB"]
R2 -.->|"import() on navigation"| C4["chunk: NewBookingPage"]
end
The dashed lines are network requests that happen when the user gets there, not before. And there's nothing to configure: writing import(...) is the whole instruction.
React.lazy and <Suspense fallback>
React.lazy and <Suspense fallback>import() returns a promise, and JSX doesn't know what to do with a promise. React.lazy is the bridge.
import { lazy, Suspense } from 'react';
const WorkshopPage = lazy(() => import('./pages/WorkshopPage.jsx'));
<Suspense fallback={<LoadingIndicator message="Loading workshop…" />}>
<WorkshopPage />
</Suspense>lazy's signature and requirements:
- It takes a function that returns a promise for a module. Not the promise itself: the function lets React call it at the right moment.
- The module must expose the component as a default export. That's what React reads from the resolved promise. CicloUrbano's convention (
export defaultfor components) fits without any changes. lazy()must be called outside of any component, at module scope. Called from inside one, every render would create a new lazy component and unmount the previous one, and the screen would flicker forever.
If a module used a named export instead, the fix is one line:
// The module exports { ChartPanel }, not a default
const ChartPanel = lazy(() =>
import('./components/ChartPanel.jsx').then((module) => ({ default: module.ChartPanel }))
);Behavior the first time versus every time after:
| Moment | What happens |
|---|---|
| First time it renders | React calls the function, the chunk request fires, the component "suspends," and Suspense shows the fallback |
| When the promise resolves | React renders the real component where the fallback was |
| Every time after | The module is already in memory: no fallback, no flicker. It's instant |
| If the promise rejects | The error bubbles up to the nearest error boundary (section 11) |
<Suspense> has one relevant prop here, fallback, and one placement rule: it must sit above the lazy component in the tree, and its position decides which part of the interface gets replaced by the loading indicator. A Suspense at the root replaces the whole app; one around <Outlet /> replaces only the page content and leaves Header and Footer untouched. That difference is very noticeable.
- Route-based splitting: the biggest payoff
Route-based splitting has the best effort-to-payoff ratio, and the reason is obvious once you say it out loud: the user is on one route at a time. Everything belonging to other routes is, by definition, code that isn't needed right now.
Here's CicloUrbano's route map with splitting applied:
// src/routes.jsx
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router';
import Layout from './components/Layout.jsx';
import CataloguePage from './pages/CataloguePage.jsx'; // 1) entry route: static
import SignInPage from './pages/SignInPage.jsx'; // 2) small and heavily used
import NotFoundPage from './pages/NotFoundPage.jsx';
import ForbiddenPage from './pages/ForbiddenPage.jsx';
import RouteErrorPage from './pages/RouteErrorPage.jsx'; // 3) NEVER lazy
import ProtectedRoute from './components/ProtectedRoute.jsx';
import RequireRole from './components/RequireRole.jsx';
import PageSkeleton from './components/PageSkeleton.jsx';
import { stations } from './data/domain.js';
// 4) On-demand chunks: one per dynamic import()
const BikeDetailPage = lazy(() => import('./pages/BikeDetailPage.jsx'));
const StationsPage = lazy(() => import('./pages/StationsPage.jsx'));
const StationDetailPage = lazy(() => import('./pages/StationDetailPage.jsx'));
const FleetTab = lazy(() => import('./pages/FleetTab.jsx'));
const IncidentsTab = lazy(() => import('./pages/IncidentsTab.jsx'));
const BookingsPage = lazy(() => import('./pages/BookingsPage.jsx'));
const NewBookingPage = lazy(() => import('./pages/NewBookingPage.jsx'));
const WorkshopPage = lazy(() => import('./pages/WorkshopPage.jsx'));
// 5) Reusable wrapper: every lazy page gets its own Suspense
function Lazy({ children }) {
return <Suspense fallback={<PageSkeleton />}>{children}</Suspense>;
}
export const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
errorElement: <RouteErrorPage />, // 6) also catches loading failures
handle: { crumb: 'Home' },
children: [
{ index: true, element: <CataloguePage />, handle: { crumb: 'Catalogue' } },
{
path: 'bicicletas/:bicicletaId',
element: <Lazy><BikeDetailPage /></Lazy>,
handle: { crumb: 'Bike details' }
},
{
path: 'estaciones',
handle: { crumb: 'Stations' },
children: [
{ index: true, element: <Lazy><StationsPage /></Lazy> },
{
path: ':estacionId',
element: <Lazy><StationDetailPage /></Lazy>,
handle: {
crumb: (params) =>
stations.find((st) => st.id === params.estacionId)?.name ?? 'Station'
},
children: [
{ index: true, element: <Lazy><FleetTab /></Lazy> },
{ path: 'incidencias', element: <Lazy><IncidentsTab /></Lazy> }
]
}
]
},
{
path: 'reservas',
element: <BookingsFrame />,
handle: { crumb: 'My bookings' },
children: [
{ index: true, element: <Lazy><BookingsPage /></Lazy> },
{
path: 'nueva',
element: <Lazy><NewBookingPage /></Lazy>,
handle: { crumb: 'New booking' }
}
]
},
{ path: 'acceso', element: <SignInPage />, handle: { crumb: 'Sign in' } },
{
element: <ProtectedRoute />,
children: [
{
element: <RequireRole allowedRoles={['operario']} />,
children: [
{
path: 'taller',
element: <Lazy><WorkshopPage /></Lazy>,
handle: { crumb: 'Workshop' }
}
]
}
]
},
{ path: '*', element: <NotFoundPage /> }
]
}
]);The six marked points:
CataloguePagestays static. It's the index route: making it lazy would add an extra network request right on the critical path. Explained in section 7.SignInPagetoo. It's small, it's the destination of everyProtectedRouteredirect, and making it lazy would introduce a flicker at a sensitive moment.RouteErrorPagemust never be lazy. If the failure it has to display is precisely a network failure, loading the error's own chunk would fail too. Error components always ship in the main bundle.- One
import()per page equals one chunk per page. No extra configuration. - The
Lazywrapper avoids repeating<Suspense fallback={...}>in nine places and guarantees the same loading experience everywhere. PlacingSuspenseinside each route, rather than around the<Outlet />inLayout, means only the page content is replaced: the header, breadcrumbs, and footer don't flicker. - The existing
errorElementcatches errors thrown while rendering child routes, includingimport()rejections. Covered in detail in section 11.
And the result, which is the whole point of the lesson:
dist/assets/index-C8k2Nx9L.js 412.30 kB │ gzip: 128.44 kB ← initial dist/assets/WorkshopPage-Bq7Wm3.js 38.12 kB │ gzip: 11.20 kB dist/assets/StationDetailPage-D2.js 44.90 kB │ gzip: 13.05 kB dist/assets/FleetTab-Kl9x2.js 12.44 kB │ gzip: 3.90 kB dist/assets/IncidentsTab-Mn4.js 14.02 kB │ gzip: 4.31 kB dist/assets/NewBookingPage-Rp8.js 31.60 kB │ gzip: 9.44 kB dist/assets/BookingsPage-Ty3q.js 22.18 kB │ gzip: 6.72 kB dist/assets/BikeDetailPage-Zx.js 18.90 kB │ gzip: 5.88 kB dist/assets/StationsPage-Ab5.js 16.30 kB │ gzip: 5.02 kB dist/assets/chart-Qw7e1.js 310.80 kB │ gzip: 94.60 kB
| Before | After | |
|---|---|---|
| Initial JavaScript (gzip) | 271 KB | 128 KB |
| Reduction | — | −53% |
| Chunks | 1 | 10 |
| Cost of navigating to the workshop | 0 (already downloaded) | One ~11 KB request |
| 08-01's budget (< 200 KB) | ❌ Not met | ✅ Met |
- Why the catalogue isn't split
It's tempting to slap lazy on everything. That would be a mistake, and understanding why is what separates someone who optimizes from someone who copies recipes.
The entry route is the one the user sees first. Making it lazy stretches the sequence out instead of shortening it:
flowchart TD
subgraph A["STATIC entry route (correct)"]
A1["Download index.js"] --> A2["Execute"] --> A3["Paint catalogue"]
end
subgraph B["LAZY entry route (mistake)"]
B1["Download index.js"] --> B2["Execute"] --> B3["Paint the fallback"]
B3 --> B4["Download catalogue chunk<br/>EXTRA request, in series"]
B4 --> B5["Execute"] --> B6["Paint catalogue"]
end
You add a round trip to the network in series, right on the critical path, and on top of that you make LCP worse by showing a skeleton first. The code saved is zero, because that code is needed anyway.
The general rule for deciding:
| Split it | Don't split it |
|---|---|
| Routes reached by navigating | The entry route |
Screens for a minority role (WorkshopPage) |
Frame components: Layout, Header, Footer |
| Modals and dialogs that have to be opened | Error components: RouteErrorPage, ErrorBoundary |
| Heavy libraries used occasionally (charts, editors, maps) | Small components: splitting 3 KB isn't worth the request |
| Tabs other than the index tab | Anything needed in the first paint |
And a rule of thumb on size: under roughly 20 KB uncompressed, a chunk of its own rarely pays off. An HTTP request has its own latency cost, and ten tiny chunks are worse than one medium one.
- Component-based splitting
Not everything splittable is a route. Within a single screen there are parts that only exist if the user does something.
Case 1: BookingDialog. It lives on the bike detail page but only mounts when you click "Book". It drags in BookingForm, its validation, and useBlockExit.
// src/pages/BikeDetailPage.jsx
import { lazy, Suspense, useState } from 'react';
import { useParams } from 'react-router';
import Panel from '../components/Panel.jsx';
import LoadingIndicator from '../components/LoadingIndicator.jsx';
import { useBike } from '../queries/bikeQueries.js';
const BookingDialog = lazy(() => import('../components/BookingDialog.jsx'));
function BikeDetailPage() {
const { bicicletaId } = useParams();
const { data: bike, isPending } = useBike(bicicletaId);
const [dialogOpen, setDialogOpen] = useState(false);
if (isPending) return <LoadingIndicator message="Loading bike…" />;
return (
<Panel title={bike.model}>
{/* … the bike details … */}
<button
type="button"
onClick={() => setDialogOpen(true)}
// Prefetch on hover: by the time it's clicked, the chunk is already there
onMouseEnter={() => import('../components/BookingDialog.jsx')}
onFocus={() => import('../components/BookingDialog.jsx')}
disabled={bike.status !== 'disponible'}
>
Book
</button>
{/* The import() does NOT fire while dialogOpen is false */}
{dialogOpen && (
<Suspense fallback={<LoadingIndicator message="Preparing the booking…" />}>
<BookingDialog
bike={bike}
onClose={() => setDialogOpen(false)}
/>
</Suspense>
)}
</Panel>
);
}
export default BikeDetailPage;The detail that makes this work: lazy doesn't download anything until the component actually renders. With the conditional render {dialogOpen && ...}, the request fires the moment it opens, not when the page mounts.
Case 2: the occupancy chart library. It's the 95 KB compressed chunk, and it's only used in a station's fleet tab, inside a weekly-occupancy collapsible section.
// src/components/OccupancyChart.jsx
// This module imports the library STATICALLY: it becomes its own chunk
import { BarChart, Axis, Bar, Grid } from 'a-chart-library';
function OccupancyChart({ data }) {
return (
<BarChart data={data} width={640} height={280}>
<Grid />
<Axis axis="x" dataKey="day" />
<Axis axis="y" />
<Bar dataKey="rentals" color="var(--color-brand)" />
</BarChart>
);
}
export default OccupancyChart;// src/pages/FleetTab.jsx
import { lazy, Suspense, useState } from 'react';
// Being the only one importing the library, the chunk includes it whole
const OccupancyChart = lazy(() => import('../components/OccupancyChart.jsx'));
function FleetTab() {
const [showChart, setShowChart] = useState(false);
return (
<>
<BikeList bikes={bikes} />
<button type="button" onClick={() => setShowChart((v) => !v)}>
{showChart ? 'Hide' : 'Show'} weekly occupancy
</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<OccupancyChart data={weekData} />
</Suspense>
)}
</>
);
}The key technique is called module isolation: you create a wrapper component (OccupancyChart) that's the only place in the project importing the heavy library, and you load that wrapper lazily. That way the bundler can put the whole library into a separate chunk. If another module imported it statically, it would fall back into the main bundle and all that effort would be wasted.
Practical check: after
npm run build, look indist/assets/for a chunk with the library's weight. If it isn't there and the main bundle is still heavy, someone is importing it statically somewhere else.
- Prefetching: making the wait invisible
Code splitting trades "waiting up front" for "waiting when you navigate." Prefetching removes that second wait by using the time the user spends deciding: the hundreds of milliseconds between the mouse reaching a link and the finger clicking it.
// src/components/PrefetchLink.jsx
import { Link } from 'react-router';
function PrefetchLink({ to, load, children, ...rest }) {
let prefetched = false;
function prefetch() {
if (prefetched) return; // once per instance
prefetched = true;
load(); // fires the import() and its chunk
}
return (
<Link
to={to}
onMouseEnter={prefetch}
onFocus={prefetch} // keyboard: same behavior (03-06)
onTouchStart={prefetch} // mobile: no hover, but there's a moment before the tap
{...rest}
>
{children}
</Link>
);
}
export default PrefetchLink;// src/components/Header.jsx (fragment)
<nav>
<PrefetchLink to="/" load={() => {}}>Catalogue</PrefetchLink>
<PrefetchLink
to="/estaciones"
load={() => import('../pages/StationsPage.jsx')}
>
Stations
</PrefetchLink>
<PrefetchLink
to="/reservas"
load={() => import('../pages/BookingsPage.jsx')}
>
My bookings
</PrefetchLink>
{isOperator && (
<PrefetchLink
to="/taller"
load={() => import('../pages/WorkshopPage.jsx')}
>
Workshop
</PrefetchLink>
)}
</nav>Why this works so well: the browser caches the module, so when lazy requests the same import() on navigation, the promise resolves immediately and Suspense never even shows the fallback. The user perceives an instant navigation even though the code was downloaded half a second earlier.
Prefetch strategies, compared:
| Strategy | When it fires | Risk | Recommendation |
|---|---|---|---|
| On hover / focus | The user points at the link | Minimal: intent is high | ✅ Default choice |
On touch (onTouchStart) |
Before the click on mobile |
Minimal: gains ~80 ms | ✅ Complementary |
After the initial paint (requestIdleCallback) |
When the thread is idle | Uses data that might go to waste | Only for 1–2 highly likely routes |
When it enters the viewport (IntersectionObserver) |
The link scrolls into view | Can over-prefetch | For lists of links |
| Everything at startup | Immediately | Defeats code splitting | ❌ Never |
An example of the third one, for the route almost everyone visits after the catalogue:
// src/components/Layout.jsx (fragment)
useEffect(() => {
const id = requestIdleCallback?.(() => {
import('../pages/BikeDetailPage.jsx'); // the next most likely route
});
return () => cancelIdleCallback?.(id);
}, []);
- Designing the wait: skeletons instead of "Loading…"
A badly designed fallback makes performance feel worse even when the milliseconds are identical. Two reasons, and both are measurable:
- Layout shift (CLS). A
<p>Loading…</p>takes up 20 pixels of height; the page that arrives takes up 900. When it's swapped in, everything jumps. If the user was already tapping something, they tap the wrong place. - Breaking context. Centered text on an empty screen says "you've left where you were." A skeleton shaped like what's coming says "this is already on its way."
// src/components/PageSkeleton.jsx
import styles from './PageSkeleton.module.css';
function PageSkeleton({ rows = 6 }) {
return (
<div className={styles.skeleton} role="status" aria-busy="true" aria-live="polite">
<span className={styles.srOnly}>Loading content…</span>
<div className={styles.title} />
<div className={styles.subtitle} />
<div className={styles.grid}>
{Array.from({ length: rows }, (_, index) => (
<div key={index} className={styles.card} />
))}
</div>
</div>
);
}
export default PageSkeleton;/* src/components/PageSkeleton.module.css */
.skeleton {
min-height: 70vh; /* reserves the height: avoids layout shift (CLS) */
padding: 1rem;
}
.title,
.subtitle,
.card {
background: linear-gradient(
90deg,
var(--color-border) 25%,
var(--color-surface) 50%,
var(--color-border) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease-in-out infinite;
border-radius: 8px;
}
.title { height: 2rem; width: 40%; margin-bottom: 0.75rem; }
.subtitle { height: 1rem; width: 65%; margin-bottom: 1.5rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.card { height: 120px; } /* same height as BikeCard */
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* Accessibility: respects the reduced-motion preference (03-06) */
@media (prefers-reduced-motion: reduce) {
.title, .subtitle, .card { animation: none; }
}
.srOnly {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
}The design decisions and their reasons:
| Decision | Reason |
|---|---|
min-height: 70vh and heights matching the real content |
The real content takes up the same space: zero layout shift |
role="status" + aria-busy + aria-live="polite" |
A screen reader announces the loading state; without this, the wait is invisible (03-06) |
| Hidden accessible text | The rectangles say nothing to someone who can't see the screen |
prefers-reduced-motion |
The shimmer animation can be distracting or cause discomfort |
| Same grid as the real content | The transition reads as a fill-in, not a screen change |
And two warnings that avoid the opposite of the intended effect:
- A
fallbackthat appears and disappears within 80 ms is worse than none at all: it reads as a flicker. If the chunk is small and there's prefetching in place, consider delaying the skeleton's appearance (only showing it if the wait exceeds ~200 ms) or just trusting the prefetch. - Don't put a
Suspenseat the root for routes. It would replace the header and footer, and every navigation would look like a full page reload.
- When the chunk doesn't arrive
This is the section that separates a toy code-splitting setup from a production one. An import() can fail, and it fails more often than it seems:
| Cause | Frequency | What the user sees if you don't handle it |
|---|---|---|
| Network down or a tunnel | High on mobile | The screen stays on the skeleton forever |
| New deploy with an old tab open | Very high in production | 404 error: the chunk with that hash no longer exists |
| Corrupted intermediate cache | Low | Error parsing the module |
| Aggressive ad blocker | Low | Request cancelled |
The second case deserves an explanation because it catches everyone off guard: Vite puts a content hash in every chunk's file name (WorkshopPage-Bq7Wm3.js). If you deploy a new version while someone has the app open, that tab keeps running the old routes.jsx, which requests a chunk with a hash that's no longer on the server. The app works perfectly… until the user navigates to a route they hadn't visited yet.
The solution combines Suspense (for the wait) with ErrorBoundary (for the failure), which is exactly the piece built in 04-05:
// src/components/LoadBoundary.jsx
import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary.jsx';
import PageSkeleton from './PageSkeleton.jsx';
import styles from './LoadBoundary.module.css';
function LoadFailure({ error, onRetry }) {
// A chunk that doesn't arrive produces a dynamic-import error
const isChunkFailure =
/Failed to fetch dynamically imported module|Importing a module script failed/i.test(
error?.message ?? ''
);
return (
<div className={styles.failure} role="alert">
<h2>This section couldn't be loaded</h2>
{isChunkFailure ? (
<p>
A new version of CicloUrbano might be available, or the connection
may have dropped. Reload the page to continue.
</p>
) : (
<p>An unexpected error occurred while opening this section.</p>
)}
<button type="button" onClick={() => window.location.reload()}>
Reload the page
</button>
<button type="button" onClick={onRetry}>
Retry without reloading
</button>
</div>
);
}
function LoadBoundary({ children, fallback = <PageSkeleton /> }) {
return (
// 1) The error boundary goes OUTSIDE: only that way does it catch the promise rejection
<ErrorBoundary fallback={(error, retry) => (
<LoadFailure error={error} onRetry={retry} />
)}>
{/* 2) Suspense inside: it handles the wait, not the failure */}
<Suspense fallback={fallback}>{children}</Suspense>
</ErrorBoundary>
);
}
export default LoadBoundary;The nesting order is not negotiable:
flowchart TD
A["ErrorBoundary<br/>catches the import() rejection"] --> B["Suspense<br/>shows the skeleton while it loads"]
B --> C["Lazy component"]
C -->|"promise pending"| D["Skeleton is shown"]
C -->|"promise resolved"| E["Page is shown"]
C -->|"promise rejected"| F["The error bubbles up to ErrorBoundary<br/>the failure is shown with a reload button"]
If you flipped it — Suspense outside and ErrorBoundary inside — the boundary would get torn down when the tree suspends, and there'd be no one left to catch the failure.
In routes.jsx, all it takes is swapping the Lazy wrapper for LoadBoundary:
With this, the root route's errorElement: <RouteErrorPage /> remains the last safety net, and LoadBoundary offers a specific, actionable message — reload — right where the failure happens, without tearing down the whole navigation. Remember why both exist: errorElement catches errors from routes and their loaders; ErrorBoundary catches errors from rendering components. An import() failure happens during render, so it's the second one's job.
Why "reload" is the right action. In the most common case — a new deploy — retrying the same import() would request the old chunk again and fail again. Reloading the page brings in the new index.html, with the correct references, and the problem goes away. That's why the primary button reloads and the secondary one retries.
- Other size levers
Code splitting is the big lever, but not the only one. These complement it, and some are cheaper to apply:
| Lever | What to do | Typical savings |
|---|---|---|
| Import only what you use | import { format } from 'date-fns' instead of import * as fns |
Depends: a lot in libraries without tree-shaking |
| Pick lighter libraries | date-fns or Intl instead of moment (which also can't be tree-shaken) |
60–70 KB compressed |
| Individual icons | import { BiBicycle } from 'react-icons/bi' instead of the whole package |
Tens of KB |
| Load libraries on demand | const { jsPDF } = await import('jspdf') inside the "Export report" handler |
The library's entire weight |
import() for data |
const { districts } = await import('../data/districts.json') for large catalogues |
The JSON's size |
| Server-side compression | Turn on gzip and Brotli on the server or CDN | Brotli typically beats gzip by 15–20% |
| Remove dead dependencies | npx depcheck to find what nobody imports anymore |
Variable, sometimes surprising |
| A modern browser target | build.target: 'es2020' in Vite, without over-transpiling |
5–15% of your own code |
An example of loading on demand inside a handler, a very useful and often overlooked pattern:
// src/pages/WorkshopPage.jsx (fragment)
async function handleExportReport() {
setExporting(true);
try {
// The PDF library (~90 KB) only arrives if someone clicks the button
const { generatePdfReport } = await import('../utils/pdfReport.js');
await generatePdfReport(incidents);
showNotice('success', 'Report generated successfully.');
} catch {
showNotice('error', 'The report could not be generated.');
} finally {
setExporting(false);
}
}No lazy or Suspense needed here: you're not loading a component, you're loading a function. A plain import() inside an async function is exactly the right tool, and the try/catch covers network failures.
And a warning about compression that's often misunderstood: gzip and Brotli reduce the transfer, not the CPU work. A 900 KB bundle that transfers as 270 KB still has to be parsed and executed as 900 KB. On mobile, that usually weighs more than the download.
Suspense is much more than this
Suspense is much more than this<Suspense> has shown up here as "what gets displayed while a chunk arrives," and that's only its simplest application. Its real role in React is more general: it's the mechanism by which a component declares it can't render yet and delegates the wait to an ancestor.
Built on top of that idea are things that don't fit in this module:
- Components that suspend waiting for data, not code: TanStack Query's
useSuspenseQuery, or React 19'suse()hook. - Streaming SSR: the server sends the HTML in pieces and fills in
Suspense's gaps as the data arrives. - React Server Components, where
Suspensemarks the boundaries between what renders on the server and what arrives afterward. - Coordination with
useTransition(08-03) so a navigation doesn't show afallbackif the new screen arrives fast enough.
All of that is the content of 10-03. For now, it's enough to have internalized the mechanism: a component suspends, the nearest Suspense above it shows its fallback, and when the wait ends the tree completes.
Common Mistakes and Tips
Calling lazy() inside a component. Every render would create a different component type, React would unmount the previous one and mount a new one, and the screen would flicker forever. lazy() always goes at module scope.
Forgetting the Suspense. A lazy component with no Suspense above it throws an explicit runtime error. It's not optional.
Putting a single Suspense at the root. Every navigation would replace the whole app, header included, and it would look like a reload. Place suspense boundaries where you actually want the swap to happen.
Making the entry route lazy. It adds a serial request on the critical path and makes LCP worse without saving a single byte.
Making error components lazy. If the network is what's failing, the error's chunk won't arrive either. RouteErrorPage and ErrorBoundary always go in the main bundle.
Splitting too much. Twenty 3 KB chunks are worse than two 30 KB ones: every request carries its own latency. Split along meaningful units — a route, a dialog, a heavy library — not file by file.
Assuming the module has a default export. If it's a named export, lazy will fail with a confusing error. Adapt it with .then((m) => ({ default: m.WhateverItIs })).
Tip: always measure before and after. npm run build before touching anything, write down the compressed figure, split, build again, and compare. Without that pair of numbers you don't know if you've improved anything.
Tip: test with a throttled network. In the browser's dev tools, set the network to "Slow 3G" and navigate around. That's where you really see whether the skeletons work, whether the prefetch arrives in time, and whether chunk failures are handled properly.
Tip: test the deploy scenario. Open the app, build again with a change (the hashes will change), and, in the old tab, navigate to a route you hadn't visited. Your "new version available" message should appear, not a blank screen.
Tip: check the bundle map every once in a while. A heavy dependency sneaks into the main bundle with a single careless static import, and nothing warns you about it.
Exercises
Exercise 1. Classify each CicloUrbano element as static, lazy by route, or lazy by component, and justify each decision in one sentence.
| # | Element |
|---|---|
| a | Header |
| b | WorkshopPage (operator role only) |
| c | Modal (used by three different screens) |
| d | ErrorBoundary |
| e | OccupancyChart (95 KB compressed library) |
| f | CataloguePage (index route) |
| g | StatusBadge (3 KB) |
| h | A rich text editor for incident notes |
Exercise 2. This code has four errors related to lazy loading. Find them, explain what each one causes, and write the fixed version.
import { lazy, Suspense } from 'react';
function StationsPage() {
const [openStation, setOpenStation] = useState(null);
const DetailPanel = lazy(() => import('../components/DetailPanel.jsx'));
return (
<div>
<StationList onOpen={setOpenStation} />
{openStation && <DetailPanel station={openStation} />}
</div>
);
}
// src/routes.jsx
const RouteErrorPage = lazy(() => import('./pages/RouteErrorPage.jsx'));
export const router = createBrowserRouter([
{ path: '/', element: <Layout />, errorElement: <RouteErrorPage /> }
]);Exercise 3. The CicloUrbano team deploys three times a day. Users who leave the tab open for hours report blank screens when navigating to the workshop in the afternoon. Explain the exact cause, describe the complete solution — including the components' nesting order — and say why the primary button must reload the page instead of retrying the import.
Solutions
Solution 1.
| # | Classification | Justification |
|---|---|---|
| a | Static | Part of the frame: needed in the first paint of every route |
| b | Lazy by route | Only a minority role sees it; it's the ideal case for route-based splitting |
| c | Static | Used by three screens, so it would end up duplicated or in the shared chunk anyway; and it's small |
| d | Static | Error component: if the network fails, its own chunk wouldn't arrive either |
| e | Lazy by component, with module isolation | 95 KB for a chart inside a tab's collapsible section. It must be the only module importing the library |
| f | Static | Entry route: making it lazy adds a serial request without saving anything |
| g | Static | 3 KB doesn't justify an HTTP request; besides, it's used on every card |
| h | Lazy by component | Heavy library only needed once you start writing an incident. Prefetch it on field focus |
Solution 2. The four errors:
lazy()inside the component. Every render creates a new lazy component, React unmounts the previous one and mounts another: infinite flicker and repeated downloads. It must go at module scope.- Missing
Suspense.DetailPanelis lazy and has no suspense boundary above it: React will throw an error trying to render it. RouteErrorPageis lazy. It's the component meant to show when something fails, including a network failure; if its own chunk doesn't arrive, there's nothing to show. It always goes in the main bundle.- Missing the
useStateimport. A trivial error, but a real one: the file only importslazyandSuspense.
Fixed version:
import { lazy, Suspense, useState } from 'react';
import PanelSkeleton from '../components/PanelSkeleton.jsx';
// Outside the component: evaluated only once
const DetailPanel = lazy(() => import('../components/DetailPanel.jsx'));
function StationsPage() {
const [openStation, setOpenStation] = useState(null);
return (
<div>
<StationList onOpen={setOpenStation} />
{openStation && (
<Suspense fallback={<PanelSkeleton />}>
<DetailPanel station={openStation} />
</Suspense>
)}
</div>
);
}// src/routes.jsx
import RouteErrorPage from './pages/RouteErrorPage.jsx'; // static, always
export const router = createBrowserRouter([
{ path: '/', element: <Layout />, errorElement: <RouteErrorPage /> }
]);Solution 3. Exact cause: Vite puts a content hash in every chunk's file name (WorkshopPage-Bq7Wm3.js). The tab open since morning keeps running that version's routes.jsx, which points to the old hashes. When the afternoon deploy happens, the server serves chunks with new hashes and deletes — or stops referencing — the old ones. When the user navigates to the workshop, a route they hadn't visited yet, import() requests a file that returns 404, the promise rejects, nobody catches the error, and the screen stays blank (or stuck on the skeleton forever, if the fallback is still mounted).
Complete solution: wrap every lazy route in a LoadBoundary with this nesting order, the only one that works:
ErrorBoundary (OUTSIDE: catches the promise rejection)
└── Suspense (INSIDE: handles the wait)
└── Lazy componentIf it were flipped, the error boundary would get torn down when the tree suspends, leaving nobody to catch the failure. The boundary's fallback must tell dynamic-import errors (Failed to fetch dynamically imported module) apart from the rest, give an understandable message — "a new version might be available" — and offer an action.
Why reload and not retry: retrying runs the same import(), which points to the same nonexistent hash, so it would fail the exact same way no matter how many times it's clicked. Reloading the page requests index.html again, which brings in the references to the new chunks, and from that point everything works. Retrying without reloading only makes sense in the other scenario — a momentary network hiccup with the same deploy still live — which is why it's offered as the secondary action.
As a further improvement, the team could publish a version file and poll it periodically to warn the user ("a new version is available, reload whenever you like") before they run into the error, and keep old chunks on the server for a few days instead of deleting them on every deploy.
Conclusion
Memoizing reduces the work React does while the user is interacting; code splitting reduces what the browser has to download and execute before the user can interact at all. They're different problems, and neither technique replaces the other: CicloUrbano's single bundle weighed 271 KB compressed — 910 KB of JavaScript to parse and execute — with half of it dedicated to screens most visits never open.
The method has been the same one running through the whole module: measure first. npm run build gives you the figure and Rollup's warning about the 500 KB; rollup-plugin-visualizer tells you what takes up that space, which is what the figure alone doesn't say. On top of that measurement, the mechanism: dynamic import() is the automatic boundary between chunks — a JavaScript syntax feature, no configuration involved — and React.lazy is the bridge between that promise and JSX, with two clear requirements (a default export, and being called at module scope) and a behavior worth keeping in mind: the first time it suspends and shows the fallback, every time after that it's instant.
Route-based splitting is the biggest payoff, and in CicloUrbano it's brought the initial JavaScript down to 128 KB compressed, a 53% cut, finally meeting 08-01's budget. Just as important is what hasn't been split: the entry route, because making it lazy adds a serial request on the critical path without saving a byte; the frame components; and above all the error components, because an error chunk that doesn't arrive because of the network is worthless. On top of that comes component-based splitting — BookingDialog, which only loads once it opens, and OccupancyChart, isolated as the sole importer of a 95 KB library — and loading libraries inside a handler with a plain import(), no lazy or Suspense needed.
After that comes everything that turns correct splitting into a good experience: prefetching on hover, on keyboard focus, and on mobile touch, which uses the user's decision-making milliseconds so the fallback never gets seen; skeletons with the height and shape of the real content, with role="status", hidden accessible text, and respect for prefers-reduced-motion, instead of a "Loading…" that causes layout shift and breaks context; and handling load failures, with ErrorBoundary outside and Suspense inside — the reverse order catches nothing — a message that tells chunk failures apart from everything else, and a button that reloads, because after a new deploy, retrying the same import() would forever request a hash that no longer exists. And the complementary levers: importing selectively, choosing lighter libraries, import() for data, Brotli on the server, and a modern browser target, with the reminder that compression reduces the transfer but not the CPU work.
It's also worth noting that Suspense is much more than a lazy-loading fallback — it's the general mechanism by which a component declares it can't render yet — and that its full story, along with Server Components, is 10-03.
With this, CicloUrbano has every tool the module set out to deliver: it knows which renders to avoid, how to stabilize identities, how to make computations cheaper, and how to spread its code out over time. What it still doesn't have is proof that any of it actually helps. Every figure in this lesson — 271 KB, 128 KB, 53% — comes from a measurement, and every memo and every useMemo from the previous lessons should come from one too. What's missing is the tool that produces them, the one that says which component repainted, how long it took, and why, turning a suspicion into a diagnosis. The next lesson is Measuring Performance with React DevTools Profiler.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
