On this page · sections
Dynamic Import & Code Chunks
import() creates async boundaries and separate chunk files loaded at runtime. Interviewers want route-level strategy and measurable perf, not just React.lazy syntax.
| Concept | Behavior | Bundler output |
|---|---|---|
Static import | Hoisted, in initial graph | Parent chunk |
Dynamic import() | Returns Promise<Module> | Separate async chunk |
| Magic comments | webpack chunk name, prefetch | Named chunks + resource hints |
manualChunks | Vite/Rollup grouping | Vendor/feature chunks |
import() Semantics
Definition
Static import ties modules into the initial graph. Dynamic import() returns a Promise resolving to the module namespace — the bundler cuts an async boundary and emits a separate chunk file.
document.getElementById('admin-btn').addEventListener('click', async () => {
const { renderAdmin } = await import('./admin-panel.mjs');
renderAdmin(document.getElementById('root'));
});
// Bundler emits:
// - main-[hash].js (click handler + import() stub)
// - admin-panel-[hash].js (loaded on first click)Main chunk → import() executes → fetch lazy chunk → evaluate module → call exportsLiteral string paths are required for static analysis. Variable paths may create a context module bundling many files. Top-level await blocks module evaluation — lazy features still use import() inside functions.
On network failure, the Promise rejects — handle with try/catch, show retry UI, pair Suspense with Error Boundaries.
Interview: Say async boundary + separate chunk file before mentioning React — shows bundler contract understanding.
Chunk Naming & Grouping
Definition
Webpack reads magic comments on the import() call:
const loadCharts = () =>
import(
/* webpackChunkName: "charts" */
/* webpackPrefetch: true */
'./features/charts.mjs'
);webpackChunkName— human-readable label for analysiswebpackPrefetch: true— idle-time fetch (rel=prefetch)webpackPreload: true— high-priority fetch for current navigationwebpackMode: "eager"— includes in parent chunk but returns async module object — rarely wanted for route splitting
Vite/Rollup equivalent:
// vite.config.js
// build: {
// rollupOptions: {
// output: {
// manualChunks(id) {
// if (id.includes('node_modules')) return 'vendor';
// if (id.includes('/features/admin/')) return 'admin';
// },
// },
// },
// },Prod URLs include content hash — chunk name is for debugging and RUM, not stable URL.
Interview: Magic comments sit inside
import()parentheses — bundlers parse them as leading comments.
Avoiding Chunk Waterfalls
Definition
A waterfall happens when chunk A loads, executes, then triggers import() for chunk B, then B triggers C — serial round-trips on the critical path.
// Waterfall — bad
async function loadDashboardBad() {
const layout = await import('./layout.mjs');
const charts = await import('./charts.mjs'); // waits for layout
return layout.mount(charts);
}
// Parallel — good when independent
async function loadDashboardGood() {
const [layout, charts] = await Promise.all([
import('./layout.mjs'),
import('./charts.mjs'),
]);
return layout.mount(charts);
}Fixes:
- Hoist shared deps into vendor chunk via
splitChunks/manualChunks - Bundle feature folders together when always used together
- Prefetch next route on hover or after idle
Promise.allfor independent dynamic imports
React.lazy per child on the same route creates nested Suspense waterfalls — colocate one lazy boundary per route.
splitChunks.chunks: 'all' dedupes shared node_modules across async chunks.
Interview: More chunks does not always mean faster — each adds HTTP overhead; merge correlated code.
React.lazy & Suspense
Definition
React.lazy wraps a function returning import('./Component') — React suspends until the chunk resolves. Suspense shows fallback UI while pending.
'use client';
import { lazy, Suspense } from 'react';
const AdminPage = lazy(() =>
import('./AdminPage').then((mod) => ({ default: mod.AdminPage }))
);
export function AppRouter({ route }: { route: string }) {
if (route !== 'admin') return <div>Home</div>;
return (
<Suspense fallback={<div>Loading admin…</div>}>
<AdminPage />
</Suspense>
);
}Route match → React.lazy triggers import() → chunk ready?
→ no: Suspense fallback
→ yes: render componentPut one lazy boundary per route segment, not per leaf component. Pair with ErrorBoundary — chunk load failures reject the Promise. Named exports need .then(m => ({ default: m.Named })).
Suspense handles pending state; boundaries handle thrown errors — compose them.
Interview: Nested lazy per component creates serial Suspense waterfalls on the same route.
Prefetch vs Preload Strategy
Definition
preload (rel=preload, fetchpriority=high) — resources needed on the current navigation.
prefetch (rel=prefetch) — low-priority idle fetch for likely next navigation.
const prefetched = new Set();
function prefetchRoute(chunkLoader) {
if (prefetched.has(chunkLoader)) return;
prefetched.add(chunkLoader);
chunkLoader();
}
document.querySelectorAll('[data-prefetch-route]').forEach((el) => {
el.addEventListener('mouseenter', () => {
prefetchRoute(() => import(/* webpackPrefetch: true */ './checkout.mjs'));
});
});<link rel="modulepreload" href="/assets/vendor-abc123.js" />Staff approach: intent-based prefetch with hit rate caps. Skip when navigator.connection.saveData is true. Service workers version cache keys on build hash to avoid stale chunk mismatch after deploy.
Next.js router.prefetch prefetches route JS and RSC payload for linked pages.
Interview: Preloading all lazy routes in index.html negates code splitting — preload only current-nav critical chunks.
Senior Interview Summary
import()
Async boundary ·
Promise<Module>· separate hashed chunk file loaded on demand.
Waterfalls
Serial A → B → C on critical path. Fix with Promise.all, vendor split, intent prefetch.
React integration
One lazy boundary per route · Suspense for pending · ErrorBoundary for rejected imports.
Prefetch vs preload
Preload = current nav critical. Prefetch = likely next route on idle. Measure hit rate.
Verification
Per-route chunk budgets in analyzer. Heavy routes must not live in main chunk.
Best mental model
Static import → pay bytes upfront
import() → pay bytes + round-trip when needed
Waterfall → multiply round-trips on critical pathPractical rules
Route splitting → one lazy boundary per route
Independent chunks → Promise.all, not serial await
Hover prefetch → cap concurrent prefetches
Deploy → immutable hashed chunks + SW cache versioningWhat to say in interviews
Explain what the bundler emits, how to fix a waterfall, where to place lazy boundaries, and when prefetch beats preload. Mention Chrome Performance initiator chain for debugging serial loads.