A team wraps a heavy charting component in `next/dynamic`, but the Next.js build output shows the route's First Load JS unchanged and the analyzer still puts the charting library in a chunk fetched on first paint. What explains that, and how would you confirm it?
answer
- a lazy edge does not cancel an eager one
- ask what else reaches this module
- the file, not the export, is the unit
- barrels and type imports hide edges
- the build output is the verdict
basics
~20 sSomething else still imports that module eagerly. A lazy import only defers a module nothing else reaches statically, so one ordinary import elsewhere in the route's graph — a barrel, a type-and-value import, a shared component — keeps the library in the eager chunk regardless of the dynamic wrapper.
solid answer
~60 sA dynamic import does not mark a module as lazy; it creates a split point that only pays off if no eager path also reaches that module. If any statically imported module in the same route's graph still imports the chart — a barrel `index.ts` that re-exports it, a sibling component importing a helper from the same file, a shared provider — the bundler must include it in the eager chunk, and the dynamic wrapper just adds a second reference to code already there. Other causes with the same symptom: the `dynamic()` call sits in a module that is itself loaded eagerly and the import specifier is computed rather than a literal, so the split cannot be formed; or the heavy module is reachable from another route and got hoisted into the shared chunk. To confirm, I look in the analyzer at *which chunk* the library landed in, then grep for every import of that module path and follow each importer upward until I find the one that is not lazy. The build output before and after is the verdict.
go deeper
Know that a dynamic import only helps if nothing else imports the same module normally, and that you should re-run the build to check the number actually moved.
Explain that reachability through static imports decides inclusion, and name the hidden edges: barrel re-exports, a helper sharing the file, a value import used only as a type.
Show the diagnosis: which chunk the module landed in, who imports it, whether each importer is itself lazy, then cut the path and verify against the previous build numbers rather than trusting the diff.
Own the structural cause — module and barrel conventions that make lazy loading achievable at all, and a check in CI so a reintroduced eager edge is caught by a build rather than discovered a quarter later.
## The rule that explains it A bundler decides what goes in the eager chunk by asking: is this module reachable from the entry through static imports? `import()` creates a *possible* boundary. It does not override a static path that also reaches the module. If both exist, the eager path wins and the module ships up front — the dynamic call then just resolves against code the browser already has. So the question is never "did we lazy-load it" but "is there any remaining eager path to it". ## The eager paths people miss **A barrel re-export.** The route imports `{ Card }` from `components/index.ts`, and that file also does `export * from './Chart'`. Whether the unused re-export is dropped depends on how cleanly the modules can be analysed; when it is not, the chart is in the graph regardless of the `dynamic()` call pointing at it. ```tsx // components/index.ts export * from './Card' export * from './Chart' // ← eager path to the chart lives here ``` **A shared file.** The chart component and a small helper live in the same module, and another component imports the helper. Module granularity is the file: pulling in the helper pulls in the file, and the file imports the charting library. **A type import written as a value import.** `import { ChartOptions } from './Chart'` for a type-only use keeps the module in the runtime graph unless it is written as a type-only import that the compiler erases. **Another route.** If a second route imports the chart eagerly and the bundler decides the module is common enough, it is hoisted into a shared chunk — which every route, including yours, downloads. ## The other failure mode: no split was formed Separately from eager paths, the split point itself can fail to exist: - The specifier is computed — ``import(`./widgets/${name}`)`` gives the bundler no single target, and it either fails or includes everything matching the pattern. - The `dynamic()` call is not where you think. Wrapping the component but then also rendering the original import elsewhere in the same tree gives you both. Keep the specifier a literal string, and when you need runtime choice, build an explicit map of keys to distinct `dynamic()` calls. ## Confirming it, in order 1. **Which chunk?** In the analyzer's client report, find the library and note the chunk. If it is in the shared chunk, the eager importer is probably outside this route entirely. If it is in this route's main chunk, the importer is inside this route's graph. 2. **Who imports it?** Grep for the module path and the package name across the source. List every importer. 3. **Is each importer itself lazy?** Follow each one upward. You are looking for one chain that reaches the route without passing through an `import()`. That chain is the answer. 4. **Cut it and rebuild.** Remove or narrow that path — deep-import instead of the barrel, split the shared file so the helper stands alone, make the type import type-only — and re-run `next build`. The route's First Load JS should drop. If it did not, there was another path. ## The fixes, matched to cause - **Barrel:** import the specific module path directly, or stop re-exporting heavy modules from the barrel at all. Barrels are convenient for authors and expensive for consumers. - **Shared file:** move the helper into its own module so importing it does not drag the chart's dependency. - **Type import:** make it explicitly type-only so it is erased at compile time and never becomes a runtime edge. - **Cross-route:** decide whether both routes should lazy-load it, or whether it belongs in the shared chunk on purpose because most sessions need it. ## The lesson worth stating in the interview Lazy loading is a property of the whole import graph, not of one call site. Adding `dynamic()` and moving on without checking the build output is the mistake — the wrapper is a hypothesis, and the route table is the experiment. Any bundle-size change you cannot see in the build output did not happen.
- Why does a barrel file so often defeat an otherwise correct dynamic import?Because the barrel re-exports the heavy module, so importing anything from the barrel puts that module in the graph. Whether the unused re-export can be dropped depends on how analysable the modules are, and a single ambiguous case keeps everything. Importing the specific path instead removes the edge entirely rather than hoping elimination catches it.
- How would you handle picking one of ten widgets at runtime without losing the split?Build an explicit record mapping each key to its own `dynamic(() => import('./widgets/Chart'))` call with a literal specifier, then index that record. Each entry is a separate, analysable split point. A template-literal specifier gives the bundler no single target and typically bundles every matching module into the split — the opposite of the goal.
- The analyzer shows the library in the shared chunk rather than this route's chunk. What does that tell you?That the eager importer is probably not in this route at all — another route or a globally rendered component reaches it, and the bundler hoisted it because it is common. Fixing only this route will not help; you have to remove or lazify the other path, or accept it as a deliberate part of the shared baseline.
saying these in an interview costs you the question
- Believes wrapping a component in dynamic() is sufficient on its own
- Never checks the build output after adding a lazy import
- Thinks unused exports are always eliminated from a barrel
- Writes a template-literal module specifier and expects one chunk
- Adds more dynamic() wrappers instead of finding the eager importer