React Code Splitting and Lazy Loading
Bundle everything into one JS file and make users download several MB before first paint? Split code by route or component into small chunks and load them only when needed. Learn React.lazy + Suspense, Next.js automatic splitting, route- vs component-level splitting, preloading, and why too many chunks can be slower.
Key Points
- Topic Focus: Next.js、React
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
You open DevTools' Network panel, refresh the initial page, and see an index-abc123.js file that is 2.4MB. Look closer: it contains the home page, settings page, the rich-text editor used only after someone opens it, the entire charting library, and three or four dependencies you cannot even name. When a user opens your home page, the browser must download all 2.4MB, then parse, compile, and execute it before it can render. In the meantime, the user sees a blank screen or a spinning skeleton.
That is the cost of "no splitting." The issue is not just slow downloads: JS is single-threaded, and the time the browser spends parsing and executing this huge bundle is often longer than the download itself, especially on low-end devices. Code splitting solves this by breaking one large bundle into small chunks loaded on demand: load only the code genuinely needed for the initial page, then load the rest when it is used. This article explains the standard approach to splitting, Next.js 16's automatic splitting, what preloading actually does, and one frequently overlooked fact: more chunks are not always better.
1. First, Understand What a Large Bundle Really Costs: Not Just Downloading, but Parsing and Execution
Many people think a large bundle is slow only because it takes longer to download. But even a 2MB JS file on fiber can download in a few hundred milliseconds. The real bottleneck is the three steps that follow:
- Parsing (parse): the browser parses text into an AST;
- Compilation (compile): JIT compiles it into executable bytecode;
- Execution (execute): it runs top-level statements and registers components.
All three steps run on the main thread, and the page is completely blocked while they execute: input does not respond and animations drop frames. Therefore, the "initial-page JS size" metric fundamentally measures how long the main thread is occupied. That is the central motivation for splitting: removing code not needed on the initial page directly shortens the time the main thread is held. A simple target is to keep initial-page JS under 200KB ideally, and keep the largest individual chunk under 500KB.
2. React.lazy + Suspense: The Standard Pattern for Loading Client Components on Demand
There is only one mechanism underneath code splitting: dynamic import(). A static import is bundled into the same bundle, while import() returns a Promise. Bundlers such as webpack and Turbopack split it into a separate chunk and fetch it at runtime when needed. React's standardized wrapper is React.lazy + Suspense:
import { lazy, Suspense } from "react";
// 动态 import → 这个组件被切成独立 chunk,用到才加载
const HeavyEditor = lazy(() => import("./HeavyEditor"));
export default function Page() {
const [editing, setEditing] = useState(false);
return (
<div>
<button onClick={() => setEditing(true)}>写文章</button>
{/* 没点之前,编辑器那坨代码根本不会下载 */}
{editing && (
<Suspense fallback={<div>编辑器加载中…</div>}>
<HeavyEditor />
</Suspense>
)}
</div>
);
}
A few key points: the function passed to lazy must use a static string path (() => import('./HeavyEditor')). You cannot construct it with a variable or template string, or the bundler cannot analyze the split point. Suspense's fallback is the placeholder shown while the chunk loads; a skeleton is far better than a blank screen. This pattern suits conditionally rendered heavyweight Client Components, such as an editor in a modal, charts expanded after a click, or dashboards shown only after sign-in. It solves component-level on-demand loading.
3. Next.js App Router: It Has Already Split Your Code Before You Do Anything
With the Next.js App Router, things are easier. Every page.tsx / layout.tsx in the App Router is naturally split by route: each route has an independent chunk, and visiting /dashboard does not load the code for /settings. More importantly, Server Components are code-split by default. They are rendered on the server and their HTML is streamed to the browser, so the client does not need to download extra component JS for them.
This leads to a common misconception: many developers coming from pure SPAs such as CRA or Vite still hand-write React.lazy everywhere after moving to Next.js, when the framework has already handled route-level splitting for them. You only need to manually lazy heavyweight Client Components that use 'use client', are large, and are not required on the initial page. In Next.js, the more idiomatic option is next/dynamic, a wrapper around React.lazy + Suspense that also provides an ssr: false option:
"use client"; // 注意:ssr: false 只能在 Client Component 里用
import dynamic from "next/dynamic";
// 只在客户端渲染(依赖 window 之类),且懒加载
const Map = dynamic(() => import("./Map"), {
ssr: false,
loading: () => <div>地图加载中…</div>,
});
Note a hard limitation in Next.js 16: ssr: false can only be used in a Client Component. Putting it in a Server Component throws an error immediately. If needed, wrap it in an outer shell using 'use client'.
4. Three Levels of Splitting: Route, Component, and Third-Party Library
In practice, splitting has three levels, from coarse to fine:
1. Route level (most common, highest return). Each route gets one chunk, and the initial page loads only the current route's code. Next.js App Router does this by default. In a pure SPA, wrap route components with route-level lazy, for example const Home = lazy(() => import('./pages/Home')). This layer is almost always worth doing: it has the biggest benefit and the fewest side effects.
2. Component level (for heavyweight components). Editors such as monaco and tiptap, charts such as recharts and echarts, maps, and Markdown renderers can each add hundreds of KB. If they only appear after a specific interaction, wrap them in lazy / dynamic as separate chunks and fetch them only when used.
3. Third-party libraries (split large dependencies separately). For large dependencies such as editors and charts, dynamically import the entire library directly in an event callback, downloading it only after the user actually triggers it:
"use client";
export default function Search() {
return (
<input
onChange={async (e) => {
// 用户真的开始搜了,才下载 fuse.js
const Fuse = (await import("fuse.js")).default;
const fuse = new Fuse(list);
setResults(fuse.search(e.target.value));
}}
/>
);
}
The criterion here is: Is this dependency required on the initial page? If not, lazy-load it. There is also a useful Next.js configuration to keep handy: optimizePackageImports. It is designed for icon and utility libraries that export hundreds of things, such as lucide and antd. It makes import { X } from 'big-lib' bundle only what you actually use, without manually importing by path:
// next.config.js
const nextConfig = {
experimental: {
optimizePackageImports: ["lucide-react", "antd"],
},
};
5. Preloading: Next.js Prefetches When a Link Enters the Viewport by Default, Not Only on Hover
This is widely explained incorrectly, largely due to a misunderstanding carried over from older Next.js versions. In production with Next.js 16, the default prefetch behavior of <Link> is to prefetch as soon as a link enters the viewport, rather than waiting for a user hover. Its scheduler also queues work by priority: links in the viewport and links with interaction intent such as hover or touch take priority, while links that leave the viewport are discarded. The design goal is simple: when the user clicks, the target route's chunk is already in cache, so navigation is nearly instant.
A few facts to remember:
- Prefetching works only in production. It does not prefetch in development mode, so do not test it locally.
- When
prefetchis omitted, it defaults to"auto": static routes prefetch the full page, while dynamic routes prefetch up to the nearestloading.jsboundary. prefetch={false}completely disables viewport prefetching, although the page still loads on demand when clicked. In long lists such as infinitely scrolling tables, dozens of Links can enter the viewport at once. Disable prefetching there, or you will download pages users never click.
// 列表项这种密集场景:关掉预取,省流量
<Link href={`/blog/${post.id}`} prefetch={false}>
{post.title}
</Link>
If you want prefetching only on hover as a compromise that saves bandwidth while retaining fast navigation, Next.js provides an official pattern: disable it by default, then enable it when the pointer moves over the link:
"use client";
import Link from "next/link";
import { useState } from "react";
export function HoverPrefetchLink({ href, children }) {
const [active, setActive] = useState(false);
return (
<Link
href={href}
prefetch={active ? null : false} // hover 后才启用默认预取
onMouseEnter={() => setActive(true)}
>
{children}
</Link>
);
}
For finer control, such as analytics, scrolling behavior, or predicted user intent, manually warm a route with useRouter().prefetch('/path'), for example when a user stays in an area from which they are likely to visit a particular next page.
6. The Right Amount of Splitting: More Chunks Are Not Always Better
At this point, you may think: why not split every component and every function into its own chunk for the ultimate result? Wrong. Splitting has costs:
- Every chunk has HTTP request overhead for connections, TLS, and headers. HTTP/2 supports multiplexing, but request scheduling, decompression, and serial execution costs remain.
- Chunks have dependencies. Loading one chunk may first require loading its dependencies, creating a waterfall: A waits for B, B waits for C, and the chained latency can be worse than one slightly larger bundle.
- Overly fragmented chunks are harder to cache effectively: change one line of code, the hash changes, and the cache is invalidated.
In practice, route-level chunks plus a small number of heavyweight component-level chunks are usually enough. Loading 3-5 chunks on a page's initial view is healthy; dozens is excessive. Next.js and webpack's default splitChunks strategy already strikes a good balance by extracting node_modules into vendor chunks and shared code into common chunks. Most projects do not need to tune splitChunks manually. What you need instead is to find unexpectedly included large dependencies, not chase more chunks.
7. Use an Analyzer to Find the Largest Piece: next experimental-analyze / @next/bundle-analyzer
Optimization without measurement is guesswork. After splitting, inspect what the bundle actually looks like. Starting with Next.js 16, there is a built-in Turbopack-based Bundle Analyzer in experimental mode. It produces an interactive module graph and shows the full import chain, making it clear who introduced a package and from where:
# 交互式分析(浏览器里看 treemap)
pnpm next experimental-analyze
# 把结果写到磁盘,方便前后对比 / 团队共享
pnpm next experimental-analyze --output
# 产物在 .next/diagnostics/analyze
For webpack projects, use @next/bundle-analyzer:
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({ /* 你的配置 */ });
ANALYZE=true pnpm build # 跑完会开几个浏览器 tab 展示 client/server bundle
Also, do not overlook the size table emitted by next build itself. It lists the First Load JS for every route, which is the amount users actually download when visiting that route. Watching this number reflects the initial-page experience more directly than watching runtime performance.
8. The Most Underrated Free Path: Move Heavy Work into a Server Component
Finally, there is a way to win without splitting at all that many developers do not realize. Code splitting answers whether the client should download a piece of JS, but the most complete optimization is to keep that JS out of the client bundle entirely. In the Next.js App Router, Server Components run on the server, and their dependencies are never bundled into client JS.
A classic example is syntax highlighting. If you use prism-react-renderer to highlight code in a Client Component, the whole highlighting library and its tokenization logic enter the client bundle. But the highlighted result is just static HTML and does not need to be computed in the browser. Move that work into a Server Component, render it on the server with shiki, and the client receives only the finished <pre><code> markup. Not one byte of the highlighting library enters the client:
// app/blog/[slug]/page.tsx —— Server Component,shiki 只在服务端跑
import { codeToHtml } from "shiki";
export default async function Page() {
const html = await codeToHtml(codeSnippet, { lang: "tsx", theme: "github-dark" });
return <pre dangerouslySetInnerHTML={{ __html: html }} />;
}
Syntax highlighting, Markdown-to-HTML conversion, and data formatting are all tasks where the input is data and the output is static UI, making them suitable for this approach. This goes a step beyond lazy: splitting only delays loading, while a Server Component does not load it at all. Before lazily loading a heavyweight component, first ask yourself: does this actually need to run in the browser?
Summary
- The cost of a large bundle is not just downloading; it is also parsing, compilation, and execution on the main thread. Initial-page JS size essentially measures how long the main thread is occupied. Reducing the bundle directly shortens blank screens and jank. Target: initial-page JS < 200KB ideally, largest chunk < 500KB.
- Dynamic
import()is the foundation of splitting. The standard wrapper isReact.lazy+Suspensefor conditionally rendered heavyweight Client Components. In Next.js, usenext/dynamic, which also supportsssr: falsefor Client Components only. - Route-level splitting in the Next.js App Router is the default behavior, and Server Components also use code splitting plus streaming rendering by default. You only need to manually
lazyheavyweight Client Components; do not wrap everything inlazy. - Prefetching means prefetching when a link enters the viewport, not only on hover, and it works only in production. Use
prefetch={false}for dense lists; use the officialHoverPrefetchLinkpattern for hover-only prefetching; userouter.prefetch()for precise control. - More chunks are not always better. Request overhead and dependency waterfalls can backfire. Route-level chunks plus a few heavyweight component-level chunks are enough; do not blindly tune
splitChunks. First inspect the bundle graph withnext experimental-analyzefor Turbopack or@next/bundle-analyzerfor webpack, and find unexpected large dependencies rather than pursuing more chunks. - The best solution is to keep code out of the client altogether. Move work such as highlighting and Markdown parsing, where data becomes static UI, into Server Components. Dependencies stay on the server and the client downloads nothing. This is more complete than
lazy.
Key takeaway: splitting means "load later," while a Server Component means "do not load". Before lazily loading a heavyweight component, ask whether it needs to run in the browser at all.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.