---
name: dynamic-import-code-splitting
description: "Audits and fixes dynamic import() and code splitting in JavaScript: failed chunk loads and stale chunks after a deploy, JSON imports without attributes, cache-busting import URLs, a 'then' export, top-level await import, React.lazy inside components or without Suspense and a boundary, and lazy loads that never split. Runs a scanner, then edits flagged lines only. Use for 'Failed to fetch dynamically imported module', 'Loading chunk failed', lazy loading, route splitting. Do NOT use for bundler config, service workers, or LCP field-data triage."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/javascript/dynamic-import-code-splitting/
  category: frontend-development
  subcategory: javascript
  version: "1.0.0"
  created: "2026-11-07"
  updated: "2026-11-07"
---

# Dynamic Import and Code Splitting: Load Late, Fail Safely

An agent works on dynamic imports in this order: decide what is worth loading late, run the scanner, fix the hard errors (JSON attributes, a `then` export), handle load failure at every `import()` site, deal with stale chunks after a deploy, fix the React `lazy` setup, tidy specifiers so the bundler can split them, remove top-level `await`, then build and test a blocked chunk. The scanner reads text. It cannot see the bundler's output, so it reports patterns and the agent confirms them against a real build.

Reusable code is in `references/patterns.md`. Availability notes and the rival comparison are in `references/appendix.md`.

## When to use this skill, and when not to

Use it when a lazy-loaded route or widget shows a blank screen after a release, `Failed to fetch dynamically imported module` or `Loading chunk failed` appears in reports, a dynamic import does not shrink the main bundle, `import("./data.json")` throws, or a code review touches `import()` or `React.lazy`.

| Request | Use instead |
|---|---|
| Catch blocks, error classes, `cause`, global handlers | `javascript-error-handling.md` |
| Floating promises, `Promise.all` and friends | `promises-async-await.md` |
| Cancelling an in-flight load | `abortcontroller-cancellation.md` |
| Long tasks while a module evaluates | `yield-to-main-thread.md` |
| LCP, INP or CLS diagnosis from field data | `lcp-optimisation.md`, `inp-optimisation.md`, `cls-optimisation.md` |
| Bundler configuration beyond chunk naming and preload hooks (Rollup, webpack plugins, module federation) | Out of scope. Use the bundler's documentation |
| Service worker caching strategies | Out of scope |

## Guardrails

1. **Propose, then change.** Report first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Deploy and cache changes are not the agent's to make.** Changing `Cache-Control` headers, retention of old assets or CDN rules affects production. Write the recommendation. A person applies it.
3. **Never run the user's code against live services to test a fix.** Use a local build, the project's tests or a test environment.
4. **Data handled.** The scanner prints file, line, rule ID and a fixed message, never source text. Chunk URLs and error payloads can hold tokens or user ids in query strings. Do not paste them into prompts or reports. Strip query strings before logging a failed import.
5. **Nothing is invented.** Every rule carries a source and a date. Bundler and browser behaviour differs by version and carries **verify before relying on this**. Size budgets come from the user, not the agent.
6. **Refuses to:** add a reload handler without a once-only guard, add `Date.now()` cache-busting to an `import()` URL, split above-the-fold content to make a bundle number look smaller, or declare a fix verified without a build and a blocked-chunk test.

## Inputs the agent must have before starting

Ask for any that are missing. Do not guess.

| Input | If missing, ask |
|---|---|
| Target files or directory | "Which folder should I scan? I will skip `node_modules`, `dist` and `build`." |
| Bundler and framework | "Vite, webpack, Next.js or no bundler? React, Vue or plain?" |
| Performance budget | "What is the initial JavaScript budget, or the LCP target? I will not choose one." |
| Deploy model | "After a release, do old hashed files stay on the server? For how long?" |
| Build and test commands | "How do I run a production build and the tests?" |
| Browser targets | "Which browsers must work? Failed-import retry differs by browser." |

## Workflow

### Step 1: Decide what to load late

Apply the table once per candidate. Do not split by habit.

| Candidate | Decision |
|---|---|
| A route the user reaches after the first screen | Split at the route. This is the default |
| A heavy widget behind a click (editor, chart, date picker, modal) | Split. Load on interaction, preload on hover or focus |
| Anything in the first screen or the likely LCP element | Do not split. A late load delays it |
| A small module | Do not split without measuring. A rival skill suggests that components under about 5 KB are not worth it. That figure is unsourced there, so treat it as a starting heuristic (skillselion lazy-loading-patterns, read 2026-11-07) |
| A module also imported statically anywhere in the main graph | It stays in the main bundle. Remove the static import or the split |
| Code needed only in some environments (polyfills, a locale) | Split, behind a feature check |

MDN states the trade-off: static imports suit initial dependencies because they allow static analysis and tree shaking. Dynamic `import()` suits deferred or conditional loading (MDN `import()`, read 2026-11-07).

### Step 2: Run the scanner

```bash
python3 scripts/import_check.py <paths>
```

Read only its output. Do not read the script. Each line is `path:line: ID level message`. Exit code 1 means errors, or warnings with `--strict`.

| ID | Level | Meaning | Fix in step |
|---|---|---|---|
| D1 | error | JSON `import()` with no `with` attribute | 3 |
| D7 | error | Module exports a name `then` | 3 |
| D2 | warn | Floating `import()`: not awaited, returned or caught | 4 |
| D11 | info | `await import()` outside a `try` | 4 |
| P1 | warn | `import()` used, no preload-error or chunk-error handler in scanned files | 5 |
| P2 | warn | `lazy()` used, no error boundary in scanned files | 6 |
| D5 | warn | `lazy()` inside a function | 6 |
| D6 | warn | `lazy()` in a file with no `Suspense` | 6 |
| D3 | warn | Cache-busting `import()` URL | 7 |
| D4 | info | Non-literal specifier | 7 |
| D9 | info | Relative `import()` without extension in a `.mjs` or `.cjs` file | 7 |
| D10 | warn | Same module imported statically and dynamically in one file | 1, 7 |
| D8 | info | Top-level `await import()` | 8 |

The scanner is text-based. P1 and P2 look only at the scanned paths, so scan the whole source tree or ignore them. D10 sees one file at a time: a static import in another file also defeats the split and the scanner cannot show it.

If the scanner reports more than 40 lines, fix errors first, then one rule ID at a time. Read only the reported lines plus 10 lines either side.

### Step 3: Fix the hard errors (D1, D7)

1. **D1.** JSON modules need an import attribute: `import("./data.json", { with: { type: "json" } })` (MDN). Node requires `type: "json"` for JSON modules and supports only the `type` attribute. Import attributes stopped being experimental in Node v23.1.0, v22.12.0, v20.18.3 and v18.20.5 (Node ESM docs, read 2026-11-07). **Verify before relying on this** if production runs an older Node.
2. **D7.** A module that exports `then()` is called during promise resolution when imported dynamically (MDN). Rename the export. If it is a public API, wrap it in an object and export that.

### Step 4: Handle load failure at the import site (D2, D11)

`import()` always returns a promise and never throws synchronously. It rejects when fetching fails (a `TypeError` in browsers), when parsing or linking fails (typically `SyntaxError`), or when the module or a dependency throws during evaluation (MDN).

Retry depends on the failure, because the module map caches some failures:

| Failure | Retry? | Source |
|---|---|---|
| Fetch failed (network, HTTP error) | Yes. The HTML standard requires failed fetches to be removed from the module map. The HTTP cache may still hand back a cached error response | MDN |
| Evaluation threw | No. Later imports return the same error without re-running the module | MDN |
| Parse error | No. It stays in the module map, so a corrected file is not re-fetched until reload | MDN |
| Older browsers without retriable loading | No. They cache the failure, so only a page reload helps | Vite docs, read 2026-11-07 |

Rules:

1. Every `import()` on a user-triggered path is awaited inside a `try`, or ends in `.catch`. A floating `import()` (D2) turns a failed load into an unhandled rejection, which `javascript-error-handling.md` step 7 covers.
2. On fetch failure retry once, after a short wait chosen by the user. If it fails again, show a message with a retry button. Do not loop.
3. Never retry an evaluation or parse failure. Report it with `cause` and show the fallback.
4. `return import(...)` and `() => import(...)` hand the promise to the caller. Check that the caller handles it. The scanner does not follow it.
5. Retry code is in `references/patterns.md`, section 1. Read it only when writing a loader.

### Step 5: Stale chunks after a deploy (P1)

A user keeps a tab open across a release. The old page asks for hashed chunk files that the new deploy removed, and the import fails with `Failed to fetch dynamically imported module`. Vite names version skew, poor networks and browser extensions as the usual causes (Vite troubleshooting, read 2026-11-07). The Netlify support guide describes the same failure with atomic deploys that delete previous files, and lists fixes that each trade complexity, user experience or stability (Netlify guide, 2024-02-26, read 2026-11-07). A webpack issue thread on `Loading chunk 0 failed` shows several unrelated causes and no confirmed root cause or maintainer fix, so do not assume one cause (webpack issue 5264, read 2026-11-07).

Apply in this order:

1. **Do not retry a chunk the deploy removed.** Retrying does not fix a permanently deleted file (Vite troubleshooting).
2. **Serve the HTML with `Cache-Control: no-cache`** so stale pages do not keep referencing old assets (Vite build docs, read 2026-11-07). Write this as a recommendation for the user.
3. **Keep old assets available for a period the user chooses.** The agent does not pick the number.
4. **Handle the event.** Vite emits `vite:preloadError` with `event.payload` holding the original error. `event.preventDefault()` stops the error being thrown. The docs show a reload as one response (Vite build docs). Use the once-only guard in `references/patterns.md`, section 2, so a missing chunk cannot cause a reload loop.
5. **Other bundlers.** webpack raises `ChunkLoadError`. Catch it at the import site (step 4). **Verify the error name for the bundler version in use.**
6. **Extensions blocking chunks.** Ad-blockers can block chunks by filename. Vite suggests changing chunk names with an output option (Vite troubleshooting). **Verify the option name for the Vite version in use**: the page read names it `build.rolldownOptions.output.chunkFileNames`.

If P1 fires on a project with no `import()` in user paths, ignore it.

### Step 6: React `lazy`, Suspense and boundaries (D5, D6, P2)

1. Define `lazy()` at module top level, never inside a component. Inside one, every render makes a new component type and the subtree remounts (rival skill rule, read 2026-11-07. **Verify against the React docs for `lazy`**).
2. Each lazy region needs a `Suspense` boundary with a fallback.
3. Suspense does not handle errors. Wrap lazy regions in an error boundary as well, or a failed chunk blanks the tree (skills.cat `implementing-code-splitting` lists this as missing in a rival, read 2026-11-07. Boundary behaviour: React reference, read 2026-11-07).
4. Named exports: `lazy(() => import("./x.js").then((m) => ({ default: m.X })))`.
5. Give the fallback the dimensions of the content it replaces. Check CLS in step 9.
6. Preload on hover or focus for routes the user is likely to take next. Do not preload everything: that restores the single large download.

D6 fires when the `Suspense` is in a parent file. Confirm that and move on.

### Step 7: Specifier hygiene (D3, D4, D9, D10)

1. **D3.** `import("/x.js?t=" + Date.now())` forces a fresh module, but the engine cannot garbage-collect the old namespaces, which leaks memory in a long-lived app (MDN). Remove the query. If code must reload, reload the page.
2. **D4.** A non-literal specifier is legal (MDN), but a bundler may be unable to analyse it. Replace it with an explicit map: `const loaders = { a: () => import("./a.js"), b: () => import("./b.js") }`. If the specifier must stay dynamic, add the bundler's ignore comment (`/* @vite-ignore */` or `/* webpackIgnore: true */`) after confirming the files are served as-is. **Verify per bundler.**
3. **D9.** Node ESM needs a file extension on relative specifiers, and directory imports need `./dir/index.js` (Node ESM docs).
4. **D10.** If a module is imported statically anywhere in the main graph, `import()` of it does not create a separate chunk (build tools report this: **verify in your bundler's output**). Pick one.
5. Importing the same module through `import()` and `import * as m` gives `===` namespace objects (MDN). Do not rely on a second import re-running side effects.

### Step 8: Top-level `await` (D8)

Top-level `await import()` blocks evaluation of the module and everything that imports it, so it defeats the lazy load. In Node, an unsettled top-level `await` ends the process with exit code 13, and CommonJS `require()` can load only ES modules without top-level `await` (Node ESM docs). Move the `await` into a function called on demand. From CommonJS, use `await import()` inside an async function (Node ESM docs).

### Step 9: Build and test a blocked chunk

1. Run the production build. Compare the initial bundle with the budget the user gave. The agent reports numbers from the build output and does not estimate them.
2. In the browser, open the network panel, load the page, and confirm the split chunk is requested only when its trigger happens.
3. Block the chunk (DevTools "block request URL", or a test-runner route abort: `references/patterns.md`, section 4), trigger it, and confirm the user sees the fallback and a retry, not a blank screen.
4. Simulate a deploy: change the chunk name, keep the old page open, trigger the import, confirm the handler reloads once.
5. Re-run the scanner. Errors must be zero.

Report with the template below. Do not echo unchanged code.

## Worked example

Input: `app.js`, 31 lines in the original, 40 in the fixed version. Scanner run on the original:

```
app.js:3: D1 error JSON import() without { with: { type: "json" } }
app.js:3: D11 info await import() outside try: confirm a caller handles the rejection
app.js:3: D8 info top-level await import(): blocks module evaluation, defeats lazy loading
app.js:6: D11 info await import() outside try: confirm a caller handles the rejection
app.js:11: D4 info non-literal specifier: bundler may not split it; check build output
app.js:15: D3 warn cache-busting import() URL: every call makes a new module that is never freed
app.js:15: D4 info non-literal specifier: bundler may not split it; check build output
app.js:19: D2 warn floating import(): not awaited, returned or caught, so a failed load is an unhandled rejection
app.js:23: D10 warn same module imported statically and dynamically in this file: no split
app.js:26: D7 error module exports a name 'then': import() will call it as a thenable
app.js:29: D5 warn lazy() created inside a function: a new component type on every render
app.js:29: D6 warn lazy() in a file with no Suspense: confirm a parent provides one
(project): P1 warn import() used but no preloadError or ChunkLoadError handler in scanned files
(project): P2 warn lazy() used but no error boundary in scanned files
scanned 1 files: 7 import() calls, 2 errors, 7 warnings
```

Decisions: line 3 is a JSON file loaded at start, so a static `import ... with { type: "json" }` replaces the top-level await (D1, D8, D11). Line 6 is a click path, so it gets `try` with `cause` (D11). Line 11 keeps a dynamic specifier on purpose: the plugin files are served as-is, so it gets `@vite-ignore` (D4). Line 15 loses its `Date.now()` query (D3). Line 19 gets `.catch` (D2). Line 23 duplicates a static import, so the dynamic import is removed (D10). Line 26 renames the export (D7). Line 29 moves `lazy()` to module level and gains `Suspense` and a boundary (D5, D6, P2). A `vite:preloadError` handler with a once-only guard is added (P1).

Second run on the fixed version: `scanned 1 files: 4 import() calls, 0 errors, 0 warnings`, exit 0. The first run exits 1.

Result report (illustrative: the call sites and test counts are invented for the example):

```
Fixed 14 of 14 flagged items in app.js. Scanner: 2 errors, 7 warnings -> 0, 0.
Build: not run, no command given. Needs a person: add Cache-Control: no-cache to the HTML response.
Behaviour change: the plugin loader still resolves at runtime, so those files are not split.
```

## Templates

### Report

```
Scope: <paths>, <bundler>, <framework>
Scanner: <errors> errors, <warnings> warnings -> <errors>, <warnings>
| file:line | ID | decision | fix |
Build: initial bundle <before> -> <after> (from build output, or "not run")
Blocked-chunk test: <fallback shown | not run>
Needs a person: <headers, retention, CDN rules>
```

### Starting prompt

```
Read dynamic-import-code-splitting.md. Scan <paths> for dynamic import and
lazy-loading problems. Bundler: <name>. Budget: <initial JS size or LCP target>.
Run the scanner and report findings as file, line, ID and proposed fix.
Do not edit until I approve a batch. Do not change cache headers or deploy settings.
```

Code templates (retry loader, preload-error handler, lazy route, blocked-chunk test) are in `references/patterns.md`. Read it only when a step names a template.

## Failure modes and anti-patterns

| # | Failure | How to spot it | Do instead |
|---|---|---|---|
| 1 | A lazy load that never splits | D10, D4. Build output shows the module in the main chunk | Remove the static import, or use an explicit loader map. Confirm in the build |
| 2 | Blank screen after a release | P1, D2. Reports show `Failed to fetch dynamically imported module` from tabs opened before a deploy | `Cache-Control: no-cache` on HTML, keep old assets, `vite:preloadError` or a catch with a once-only reload |
| 3 | Retry loops | A handler that reloads or retries with no guard, or retries an evaluation error | Retry fetch failures once. Never retry parse or evaluation failures. Guard reloads with a flag |
| 4 | Splitting the first screen or tiny modules | LCP gets worse after the change. Many small requests in a waterfall | Step 1 table. Measure before and after |
| 5 | Surprises from `then`, `Date.now()` and top-level `await` | D7, D3, D8 | Step 3, step 7 and step 8 |

## Quality bar

Run against the output before handing over. Each item is a check, not an opinion.

1. Scanner exit code is 0, or each remaining warning is justified in the report.
2. Every `import()` on a user path is in a `try` with `await`, ends in `.catch`, or is returned to a caller named in the report.
3. No `import()` URL contains `Date.now()`, `Math.random()` or a `t=` query.
4. No module exports a name `then`.
5. Every `lazy()` is at module level and sits under a `Suspense` and an error boundary.
6. Every reload handler has a once-only guard (`grep -n "preloadError"` and read the matching lines).
7. The report quotes initial bundle numbers from the build output, or says "not run".
8. The blocked-chunk test was run or listed as not run, with the reason.
9. Cache-header and retention changes appear under "Needs a person", not in the diff.
10. The diff touches only flagged lines and the files needed for the handler.

## Cost

Figures are estimates from this skill's own count on the final files (words × 1.35 for prose, characters ÷ 3.5 for code and tables). No tokeniser could be installed in the build environment, so none is measured. Rival figures are estimates from the visible text of pages I read.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 111 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 5,542 | Estimate | When the skill fires |
| C. `references/appendix.md` | 772 | Estimate | Only for availability notes or the rival comparison |
| C. `references/patterns.md` | 1,017 | Estimate | Only when a step names a template or code block |
| Script output (typical run) | 278 | Estimate | Scanner output on the worked example (1385 characters) |
| D. Typical run (A + B + script output) | 5,931 | Estimate | Common path, no references |
| D. Worst case (A + B + both references + 40 lines of output) | 8,202 | Estimate | Large input, every branch |
| E. Cheaper path | 5,931 | Estimate | Run the scanner, fix only flagged lines, skip both references |

Rival bodies I could size run from about 340 to about 1,890 tokens. This skill costs more to load. In return the scanner finds the patterns so the agent does not read every file to look for them, and the failure handling is in the skill, not left to the agent.

## Handoffs

`javascript-error-handling.md` covers what to do with the error once an import rejects: causes, logging and global handlers. `promises-async-await.md` covers floating promises. `yield-to-main-thread.md` covers long tasks while a loaded module runs. The marketing skills `lcp-optimisation.md` and `javascript-seo.md` cover field-data diagnosis and rendering. This skill does not repeat them.

## Appendix: tool-specific notes

Kept short. Version details, support notes and the rival comparison are in `references/appendix.md`. Read it only for availability notes or the rival comparison.

| Tool | Note |
|---|---|
| Vite | `vite:preloadError` event, `event.payload`, `preventDefault()`. Retriable module loading depends on the browser |
| webpack | `ChunkLoadError`. Magic comments such as `webpackIgnore`. **Verify against the version in use** |
| Node | `with { type: "json" }`. Extensions mandatory. `require()` of ESM only for modules without top-level `await` |
| React | `lazy`, `Suspense`, class-component error boundaries |
| Service workers and worklets | `import()` rejects there (MDN). Use static imports |

## Sources

Last verified 7 November 2026 for every source.

- MDN, import(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import
- Node.js docs, ECMAScript modules: https://nodejs.org/api/esm.html
- Vite docs, Building for production: https://vite.dev/guide/build.html
- Vite docs, Troubleshooting: https://vite.dev/guide/troubleshooting.html
- Netlify support guide, code-splitting issues (26 February 2024): https://answers.netlify.com/t/support-guide-handling-code-splitting-issues-on-netlify/113158
- webpack issue 5264, Loading chunk failed: https://github.com/webpack/webpack/issues/5264
- React reference, Component (error boundaries): https://react.dev/reference/react/Component
- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- Rival, skillselion lazy-loading-patterns: https://skillselion.com/skills/yonatangross/orchestkit/lazy-loading-patterns
- Rival, skills.cat implementing-code-splitting: https://skills.cat/skills/djankies/claude-configs/implementing-code-splitting
- Rival, openskillindex js-dynamic-import (metadata only): https://openskillindex.com/skills/intense-visions-harness-engineering-js-dynamic-import-bd5432
- Adjacent, cloudfront esm-module-patterns: https://d8uzp82hd82jh.cloudfront.net/agent-skills/esm-module-patterns-2

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/javascript/dynamic-import-code-splitting/
Download: https://afeezadebayo.com/wp-content/uploads/skills-md/frontend-development/javascript/dynamic-import-code-splitting/SKILL.md

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.
