Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ES2020+ ~23 min· topic 2 of 6

Topic 12.2

Dynamic import() and Code Splitting

In one line

import(specifier) loads a module while the program is running and returns a promise of its namespace object, so code can be fetched only when it is needed; bundlers use it to split an app into smaller files called chunks.

Think of it like this

A library and a reading room. You don't carry every book in the library home on the first day; you take the one you are reading now. When you need another one, you ask the librarian, wait a moment, and it arrives. Static import is packing your bag before you leave. **Dynamic import()** is asking the librarian for a book at the moment you need it, and if you ask for the same book twice, you get the copy you already have.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Dynamic import
The import(...) expression, which loads a module while the program is running and returns a promise.
Static import
An import ... from declaration at the top of a module, loaded before any code runs.
Namespace object
The object import() gives you: each export is a property, and the default export is the property default.
Promise
An object standing for a value that will arrive later, or an error if it fails (Phase 10).
Code splitting
Cutting an app's JavaScript into several files so each page downloads only what it needs.
Chunk
One of the output files a bundler makes when it splits code; loaded on demand.
Lazy loading
Waiting to load something until it is really needed, instead of at start-up.
Prefetch
Starting to download something a little before it is needed, so it is ready when the user asks.

Step by step

01The problem: one big file

Imagine a notes app. Everyone uses the note list, but only a few people open the charts screen, which needs 200 KB of chart code. With a static import, every visitor downloads, parses and runs those 200 KB before the page becomes interactive.

main.jswhole filejavascript
import { renderList } from "./list.js";
import { drawChart } from "./chart.js"; // 200 KB, used by few people

renderList();
document.querySelector("#stats").addEventListener("click", () => {
  drawChart(document.querySelector("#canvas"));
});

02Move the import to where it's needed

Replace the static import with import() inside the click handler. The handler becomes async so it can await the promise. The chart code now downloads only on the first click.

main.jswhole filejavascript
import { renderList } from "./list.js";

renderList();
document.querySelector("#stats").addEventListener("click", async () => {
  const { drawChart } = await import("./chart.js");
  drawChart(document.querySelector("#canvas"));
});

03Handle loading and failure

The first click may take a moment on a slow connection, and it can fail. Show a loading state, disable the button so it isn't clicked five times, and catch errors.

main.jswhole filejavascript
button.addEventListener("click", async () => {
  button.disabled = true;
  status.textContent = "Loading charts...";
  try {
    const { drawChart } = await import("./chart.js");
    drawChart(canvas);
    status.textContent = "";
  } catch (err) {
    status.textContent = "Couldn't load charts. Check your connection and try again.";
    console.error(err);
  } finally {
    button.disabled = false;
  }
});

04Let the bundler split it

Build the app with a bundler such as Vite (Topic 12.4). It sees the import("./chart.js") and puts chart.js in its own chunk with a hashed name. The main file stays small.

terminal
$ npm run build
── expected output ──
vite v7.1.5 building for production...
✓ 4 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/chart-DkF3a9Qe.js 2.84 kB │ gzip: 1.31 kB
dist/assets/index-B7xQ2LmA.js 1.12 kB │ gzip: 0.64 kB
✓ built in 96ms

05What happens in the browser

On page load, only index-*.js is downloaded. The first click asks for the chart chunk; the second click finds it already in the module map.

What happens in the browserdiagram
Rendering diagram…

06Load a little early

To hide the wait, start the import when the pointer moves over the button. The promise is stored, so the click reuses the same download instead of starting another.

main.jswhole filejavascript
let chartPromise = null;
const loadChart = () => (chartPromise ??= import("./chart.js"));

button.addEventListener("pointerenter", loadChart, { once: true });
button.addEventListener("click", async () => {
  const { drawChart } = await loadChart();
  drawChart(canvas);
});

Try it yourself

  1. 1

    Click twice

    In the first example, press Show the chart yourself after the automatic click. Predict: does chart module: downloaded and run print again? What about Drew 3 bars?

  2. 2

    Break the module on purpose

    In the first example, change export function bar to export function bar( (an extra bracket) inside the URL. Predict what the page shows. Then wrap the await import(...) in try/catch and show Couldn't load the chart on the page instead.

  3. 3

    Time it

    In the third example, replace Promise.all with two separate await import(...) lines. The result is the same here because data: URLs load instantly; on a real network, which version is faster, and why?

Code & diagrams

Load a module only when the button is pressed New tab

The module's first line ran only after the click. In a real app the URL would be `"./chart.js"` and the bundler would put it in its own chunk. Press the button again: the module message does not repeat.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Page ready. The chart module is not loaded yet.
chart module: downloaded and run
Drew 3 bars
The promise, the namespace object, caching and errors New tab

The default export lives on the property `default`; destructuring renames it with `default: greet`. The second import didn't run the module again. A module that throws makes the promise reject, so `catch` sees the error.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

import() returned a Promise
greeting module runs
Hi Asha version 2
second import: same function? true version 2
Caught: the module crashed while running
Parallel loading and choosing a module at run time New tab

`Promise.all` starts both imports before waiting, so they load in parallel. A polyfill is code that adds a missing feature to old browsers; loading it conditionally means modern browsers never pay for it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

5 LOADED TOGETHER!
chosen at run time: [ 'shout' ]
toSorted exists, so the polyfill was never downloaded
Route-based splitting in a single-page appjavascript
// router.js: each page is its own chunk, loaded on first visit
const routes = {
  "/": () => import("./pages/home.js"),
  "/settings": () => import("./pages/settings.js"),
  "/reports": () => import("./pages/reports.js"), // pulls in the charts library
};

export async function show(path) {
  const load = routes[path] ?? routes["/"];
  const page = await load();          // { default: renderPage }
  page.default(document.querySelector("#app"));
}

// React builds the same idea in:
// const Settings = React.lazy(() => import("./pages/settings.js"));

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the await

In the first example, change const chart = await import(CHART_URL); to const chart = import(CHART_URL);.

terminal
$ Console
── what you'll see ──
Uncaught (in promise) TypeError: chart.bar is not a function

Break #2

Call the namespace instead of the default export

In the second example, add const greetFn = await import(GREET_URL); greetFn("Ravi"); inside main.

terminal
$ Console
── what you'll see ──
Uncaught (in promise) TypeError: greetFn is not a function

Myth vs fact

Myth

import() is a normal function, so you can store it in a variable or call it with .call().

Fact

It is special syntax, like super(...). const load = import; is a SyntaxError. Wrap it in an arrow function instead: const load = (p) => import(p);.

Myth

Each import() call downloads the file again.

Fact

The browser keeps one module per URL. Later calls reuse the already-loaded module and resolve almost immediately.

Myth

Splitting code always makes a site faster.

Fact

It makes the first load lighter, but the user waits when they reach the split feature. Too many tiny chunks add request overhead. Split big, rarely used features, and prefetch likely ones.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Bundlers can only split what they can see. import("./pages/" + name + ".js") works in Vite and webpack because they turn the pattern into "any file in ./pages/" and make a chunk for each; a fully dynamic import(userInput) can't be analysed and is left to the browser at run time. webpack also reads comments such as import(/* webpackChunkName: "chart" */ "./chart.js") to name chunks.

  • ▸

    When a new version is deployed, old chunk files may be deleted while a user still has the old page open; their next import() then fails with a 404. Keep old hashed assets for a while after deploys, or catch the error and reload the page. Vite fires a vite:preloadError event for exactly this case.

  • ▸

    Vite rewrites each import() to also preload the chunk's own dependencies in parallel (it injects modulepreload hints for them), avoiding a waterfall where the chunk loads and only then discovers its imports.

  • ▸

    import() also accepts a second options argument. With import attributes (ES2025), import("./config.json", { with: { type: "json" } }) loads JSON as a module; the static form is import config from "./config.json" with { type: "json" };.

Remember this

  1. 1

    import(specifier) (ES2020) looks like a function call, but it is special syntax built into the language. It can be used anywhere: inside an if, inside a click handler, inside a loop, and in classic scripts as well as modules. The specifier can be any expression, worked out at run time: import("./pages/" + pageName + ".js"). The specifier rules are the same as for static imports (./, full URLs, or names an import map knows).

  2. 2

    It returns a promise (Phase 10). When the module and all its dependencies have loaded and run, the promise fulfils with the module's namespace object: one object whose properties are the module's exports. Named exports are properties (mod.withTax), and the default export is the property called default (mod.default). With await and destructuring: const { default: formatPrice, TAX } = await import("./prices.js");.

  3. 3

    If anything fails (the file is missing, the network drops, the module has a syntax error, or its top-level code throws) the promise rejects. Handle it with try/catch around await import(...) or .catch(), and show the user something useful, such as "Couldn't load the editor, please retry". A failed import is not cached as success, so retrying later can work.

  4. 4

    Dynamic imports share the module map with static ones: each URL is fetched and evaluated once. Calling import("./chart.js") in a click handler a hundred times downloads it once; later calls resolve with the same namespace object almost immediately. You don't need your own "already loaded?" flag, though keeping the promise in a variable is a common way to start loading early and reuse it.

  5. 5

    Code splitting means cutting an app's JavaScript into several files so the first page load only downloads what the first screen needs. Bundlers (Topic 12.4) treat every import() as a split point: the imported module and its private dependencies go into a separate chunk file, fetched on demand. Typical candidates: a rich text editor, a charts library, an admin-only page, a rarely used dialog. Each route of a single-page app is often its own chunk (route-based splitting); React's lazy(() => import("./Settings.js")) is built on exactly this.

  6. 6

    Splitting has a cost: the user waits the first time they need the chunk. Hide that wait by loading a little early. Start the import when the pointer hovers the button or when the browser is idle (prefetching), or add <link rel="modulepreload" href="/assets/chart.js"> for chunks you know will be needed soon. Don't split tiny modules: every extra request has its own overhead, and a chunk of 1 KB isn't worth a round trip.

Explain it without notes

01

How does import() differ from a static import declaration?

02

What does the promise from import() resolve to, and how do you reach the default export?

03

What is code splitting, and how does a bundler decide where to split?

04

What are the costs of lazy loading, and how do you reduce them?

Practice

01

Write an async function loadAndRun(url) that imports a module, calls its default export with no arguments, and returns the result; if anything fails it returns the string failed.

02

Turn this into lazy loading: import { format } from "./big-date-lib.js"; button.onclick = () => (out.textContent = format(new Date()));.

Trade-offs

  • ↔

    Lazy loading shrinks the first download and speeds up start-up, but moves a wait (and a possible network failure) to the moment the user needs the feature.

  • ↔

    Route-level chunks are simple and effective; very fine-grained splitting gives diminishing returns and more requests to manage.

Done when you can

  • I can load a module with await import(...) and read named and default exports from the namespace object.

  • I handle loading states and failures around a dynamic import.

  • I know a module is fetched and run once, however many times it is imported.

  • I can explain code splitting, chunks and why bundlers split at import().

  • I know ways to hide the wait: prefetch on hover, idle loading and modulepreload.