How do you approach code splitting in a large frontend application?
Per-route (automatic in frameworks), per-heavy-widget (modals, editors, charts), per-locale, per-feature-flag. Vendor chunking by stability + usage (not one mega chunk). Prefetch likely-next routes on hover/viewport. Budget in CI (size-limit). Avoid waterfalls (chunk → loads → another chunk). RSC where possible (server-only code doesn't ship). Watch for over-splitting (HTTP overhead) and the loading-flash UX cost on the critical path.
Code splitting is a system design concern: where you split determines bundle size, cache invalidation patterns, loading UX, and the deploy story.
Levels of splitting
1. Per route. The default and biggest win. Each page is its own chunk. Frameworks (Next.js, Remix, React Router lazy routes) handle this automatically.
2. Per heavy widget. Modals, rich-text editors, chart libraries, code editors, file uploaders — anything loaded on interaction or scroll.
const Editor = lazy(() => import('./Editor'));
{editing && <Suspense fallback={<Spinner />}><Editor /></Suspense>}3. Per locale. Don't ship 30 languages of translations.
const messages = await import(`./locales/${locale}.json`);4. Per feature flag / experiment. Hidden flags shouldn't ship to users.
5. Per platform. Mobile-only or desktop-only features.
Vendor chunking
The default "all node_modules in one chunk" is bad: a single dep change invalidates the whole vendor cache. Better:
- By stability: React core (stable) separate from utility libs (changes often).
- By usage: deps used only on certain routes belong in those route chunks.
- Large deps standalone:
chart.js,mapbox-gl,pdfjseach get their own chunk.
Prefetching
<Link href="/admin" prefetch>Admin</Link> // Next.js prefetches in viewportOr on hover:
<a href="/admin" onMouseEnter={() => import('./admin/page')}>Admin</a>Prefetched chunks land in HTTP cache; the actual navigation is instant.
Avoiding waterfalls
A chunk that requires another chunk creates a sequence: load A → discover B → load B. Total = A latency + B latency.
Fix:
- Co-locate dependencies: bundle them together when always used together.
- Prefetch B when A loads.
- Don't dynamic-import inside lazy chunks unnecessarily.
Chunk size sweet spot
- < 30KB: HTTP overhead dominates.
- > 500KB: defeats the splitting purpose.
- Sweet spot: 50-200KB compressed per chunk.
For HTTP/2/3 multiplexed connections, slightly more smaller chunks is fine.
Caching with hashed filenames
main.abc123.js
admin.def456.jsEach chunk has content-based hash → URL changes when content changes. Long max-age + immutable. Invalidation is free.
CI budget
[
{ "name": "main", "path": "dist/main.*.js", "limit": "150 KB" },
{ "name": "vendor", "path": "dist/vendor.*.js", "limit": "200 KB" }
]PR fails on regression. Without enforcement, splits get undone over time.
UX trade-offs
Splitting adds loading state on first hit. If the chunk is small, it's instant; if huge, you flash a spinner.
Strategies:
- Prefetch predictable navigations so the chunk is ready before click.
- Skeleton matching final layout during Suspense fallback.
- Don't split critical-path code behind a click that demands instant response.
Modern context: RSC
React Server Components don't ship JS to the client at all. Server-only logic stays on the server. For pages that are mostly presentational + server-data, RSC is strictly better than splitting — there's nothing to split.
'use client' boundaries explicitly mark what does need to ship. The "code split" mental model maps cleanly to RSC's "what hydrates."
Pitfalls
- Splitting on the critical path → visible loading spinner.
- Mega vendor chunk → cache invalidation pain.
- Tiny chunks → HTTP overhead exceeds payload.
- No prefetching → every nav waits.
- Lazy + Suspense waterfalls → nested Suspense boundaries flash one at a time.
- Forgetting CI budgets → splits regress.
Decision flow
- Route-level: automatic in framework. Verify.
- Heavy widget: split if >30KB and not always rendered.
- Vendor: chunk by stability/usage.
- Prefetch: hover + viewport for predictable navs.
- Budget: enforce in CI.
- RSC: use where possible to skip the JS entirely.
Architecture impact
Splitting decisions affect:
- Cache patterns: hashed URLs + long cache.
- Deploy story: graceful handoff between old and new bundle hashes.
- Observability: track chunk-load failures separately from app errors.
- A/B testing: variants can ship as separate chunks.
Mental model
Splitting is budget allocation: define the initial-JS budget, fit critical code in it, defer the rest with prefetch hints, enforce in CI. RSC removes much of the need for client-side splitting by keeping code on the server.
Follow-up questions
- •How do React Server Components change splitting?
- •What's the right vendor-chunking strategy?
- •How do you avoid lazy-load waterfalls?
- •What's the cost of over-splitting?
Common mistakes
- •Mega vendor chunk — invalidates on any dep update.
- •Splitting tiny components — HTTP overhead.
- •Splitting on the critical path.
- •No prefetch — every nav round-trips.
- •Waterfalls from chained dynamic imports.
- •No CI budget — splits regress silently.
Performance considerations
- •Smart splitting drops initial JS 40-70% on typical apps. Prefetch hides chunk-load latency for predictable navigation. The win shows up in TTI/INP/LCP; the cost is loading state on first hit.
Edge cases
- •Magic comments for stable chunk names (better caching).
- •Locale chunks: dynamic-import path patterns can match too much.
- •Module Federation: shared deps across federated apps.
- •SSR: lazy components render fallback on server; hydration must match.
- •RSC: server-only code doesn't ship — pure win.
Real-world examples
- •Next.js auto-splits by route + prefetches in viewport.
- •Slack pre-caches likely-next channels.
- •Stripe Dashboard splits per major route.
- •Vercel uses RSC extensively in their dashboard.