Code Splitting and Lazy Loading in React: A Practical Performance Guide
A typical React application starts as a single JavaScript bundle. As the application grows — more pages, more features, more dependencies — that bundle grows too. A 50KB bundle becomes 200KB, then 500KB, then 1MB. Users on slow connections wait seconds for the initial page to become interactive because the browser must download, parse, and execute the entire bundle before anything works.
Code splitting fixes this by breaking the single bundle into smaller chunks that load on demand. Instead of downloading everything upfront, the browser downloads only what is needed for the current page. Other chunks load lazily when the user navigates to them.
What Code Splitting Actually Does
Without code splitting: browser downloads main.js (800KB) → parses all 800KB → executes all 800KB → page becomes interactive.
With code splitting: browser downloads main.js (150KB) → parses 150KB → page becomes interactive → user navigates to /settings → browser downloads settings.js (50KB) → settings page renders.
The initial load time drops dramatically because the browser processes less JavaScript upfront. Subsequent navigations might show brief loading states while chunks download, but the trade-off is overwhelmingly positive — faster initial loads matter more than marginally slower navigation.
Route-Based Splitting in Next.js (Automatic)
If you are using Next.js, congratulations — route-based code splitting is automatic. Each page in the pages/ or app/ directory becomes its own chunk. Navigating to /tools loads only the JavaScript for the tools page, not the JavaScript for /blog, /pricing, or /admin.
Next.js also splits: page-specific CSS, imported libraries only used on specific pages, and server-only code (which is never sent to the client at all in Server Components).
If you are using Next.js App Router with Server Components, you already have the best code splitting available. Server Components send zero JavaScript. Client Components are automatically chunked per component.
Manual Code Splitting with Dynamic Imports
For components that are expensive but not immediately needed, use dynamic imports:
Instead of importing a heavy component at the top of the file (which includes it in the page's bundle), use next/dynamic or React.lazy to import it only when needed.
Common candidates for dynamic imports: modal content (the modal body is invisible until opened), below-the-fold components (image galleries, comment sections), admin features (only visible to admins), and heavy third-party libraries (code editors, charts, maps).
The Suspense Pattern
React.lazy and dynamic imports need a loading fallback. What does the user see while the chunk downloads?
Wrap lazy components in Suspense with a fallback: a loading spinner, a skeleton screen, or a simple "Loading..." text. The fallback shows while the chunk downloads and the component renders.
For the best UX: use skeleton screens that match the component's final layout. This prevents layout shift when the real component replaces the fallback.
Library-Level Code Splitting
Some npm packages are enormous. Moment.js is 300KB. Lodash is 70KB. If you import one function from lodash, the entire library might end up in your bundle (depending on tree-shaking effectiveness).
Solutions:
Import only what you need: import { debounce } from 'lodash-es' instead of import _ from 'lodash'. ES module imports enable tree-shaking — the bundler includes only the imported function.
Use smaller alternatives: date-fns instead of moment.js (tree-shakable, import only needed functions). Or use the built-in Intl API for formatting.
Dynamic import for rare usage: if you use a PDF generation library in one admin feature, dynamic-import it only when that feature is activated. Do not include it in the main bundle.
Analyzing Your Bundle
You cannot optimize what you cannot measure. Use bundle analysis to see what is in your JavaScript:
For Next.js: @next/bundle-analyzer generates a visual treemap of your bundle. Each rectangle represents a module, sized by its byte contribution. This immediately reveals: unexpected large dependencies, duplicate modules (the same library included multiple times), and chunks that could be split further.
Run the analysis periodically (monthly or before major releases). Check that no single chunk has grown unreasonably. Investigate any dependency that contributes more than 50KB.
The Third-Party Script Problem
Third-party scripts (analytics, chat widgets, A/B testing tools) often add significant JavaScript outside your control. A chat widget might add 200KB. An analytics library might add 50KB. A heatmap tool might add 100KB.
Strategies: load third-party scripts with the defer or async attribute, use dynamic imports to load them after the page is interactive, lazy-load chat widgets (show a "Chat" button that loads the widget on click rather than loading it immediately), and use the Partytown library to offload third-party scripts to a Web Worker (removing them from the main thread entirely).
Preloading Critical Chunks
Code splitting defers loading, but sometimes you can predict what the user will need next. Preloading fetches a chunk before the user requests it — so when they navigate, the chunk is already cached.
Prefetch on hover: when the user hovers over a navigation link, start loading the destination page's chunk. By the time they click, the chunk is ready. Next.js Link component does this automatically with its prefetch prop.
Prefetch on viewport entry: when a link becomes visible on screen, prefetch its destination. This works well for: navigation menus (prefetch all items when the menu opens), tool cards (prefetch the tool detail page when the card is visible).
When NOT to Code Split
Splitting too aggressively creates problems:
Too many small chunks: each chunk requires a network request. 50 tiny chunks (1KB each) is worse than one 50KB chunk because of HTTP request overhead.
Splitting critical UI: the above-the-fold content should load in the main chunk. Do not lazy-load your header, navigation, or hero content — they are visible immediately and should render without delay.
Splitting tiny components: a 2KB component does not benefit from code splitting. The overhead of the additional network request and Suspense boundary costs more than the bytes saved.
Performance Budget
Set a performance budget: the main bundle should be under 200KB (gzipped). Individual route chunks should be under 100KB. Third-party scripts should total under 100KB.
Enforce the budget in CI: fail the build if any chunk exceeds the limit. This prevents gradual bundle growth from going unnoticed.
Our budget at AIDToolStack: main chunk is 140KB (gzipped), individual pages are 20-60KB, and third-party scripts total 40KB (Plausible + Sentry). Total initial load: 180KB of JavaScript for the homepage. This loads in under 1 second on 3G connections.
Code splitting is not premature optimization — it is a fundamental architecture decision that should be made early. The patterns are well-established, the tooling is mature, and the performance impact is immediately measurable. Ship less JavaScript, and your users get a faster experience on every device and every connection speed.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.