cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Dynamic import & top-level await

Load JavaScript modules on demand, understand dynamic import promises, code splitting, top-level await, import.meta, import.meta.resolve, and current module-loading proposals.

By the end, you can
  • 01
    Load code on demandUse import() as a promise for a module namespace, including computed specifiers and rejection handling.
  • 02
    Reason about waitingExplain module caching, code splitting, and how top-level await delays only dependent modules.
  • 03
    Inspect module identityUse import.meta.url and import.meta.resolve(), and recognize import defer and import source as proposals.

Load modules when the app actually needs them

Static imports are wonderful when code is required immediately. A module says, “before I run, load these dependencies.” That gives browsers and bundlers a clear graph. But real applications also have features that might never be opened: a chart screen, a markdown editor, an admin panel, a language pack, or a payment widget.

Dynamic import is the expression import(specifier). It runs when normal JavaScript reaches that expression, accepts a string specifier that can be computed at runtime, and returns a Promise for the module namespace object. It works in classic scripts and in modules, because it is an expression rather than a top-level declaration.

Real-life analogyStatic imports are prep; import() is ordering on demand

Imagine a restaurant that prepares every possible dish before unlocking the door. Service is instant, but the kitchen wastes time on food nobody orders. Dynamic import lets the kitchen wait for a real request. The guest gets an order token, and the dish arrives when it is ready.

In real life: The restaurant preps every dish before opening
In JavaScript: Static imports load required modules before evaluation
In real life: A guest orders a special dish only when they want it
In JavaScript: import() starts loading only when that line runs
In real life: You receive an order token while food is prepared
In JavaScript: import() returns a promise while the module loads
In real life: The dish arrives ready to serve
In JavaScript: The promise fulfills with the module namespace object

Where the analogy stops: Restaurants can improvise with ingredients. JavaScript still resolves a precise module specifier, and bundlers often need a bounded pattern for computed paths.

This lesson covers the whole advanced module-loading toolkit: import(), code splitting, top-level await, import.meta, import.meta.resolve(), and two proposal-shaped imports you should recognize without treating them as everyday browser syntax.

import() returns a promise for a module namespace

INTERACTIVE

Read the code first. Line 1 does not give you the exports directly. It gives you a promise. After await, line 3 receives the module namespace: a read-only object whose properties are the module’s exports. If the same normalized URL is imported again, JavaScript reuses the already evaluated module instance.

import() lab: promise, namespace, cache, rejection
Dynamic import flowPop out in the code editor (opens in a new tab)JavaScript
const pending = import("counter");console.log(pending instanceof Promise);const counter = await pending;console.log(counter.next());const again = await import("counter");console.log(again === counter);await import("broken");
Sandbox logreal modules

Choose a control to import a module.

Try it yourself

The iframe runs real module code with an import map whose specifiers point to predefined data URL modules. The parent accepts messages only from that exact sandboxed frame.

Sandboxed with allow-scripts and srcdoc. No learner code or network request is evaluated.

Notice four professional details in the lab:

  1. The promise is created immediately. This is why you can show a loading state or race it against a timeout just like promises from the Promises lesson.
  2. The fulfillment value is the namespace object, not the default export. If a module has a default export, read it as module.default.
  3. Modules are cached per URL after successful evaluation. Shared module state is shared whether the import was static or dynamic.
  4. Broken loading, parse errors, missing dependencies, or a thrown top-level error reject the promise. Use try/catch around await import(...).
Computed specifiers are allowed

JavaScript allows import("./locales/" + language + ".js"). Bundlers may require a pattern they can discover, such as all files in a known locale folder. Keep user input bounded; do not turn arbitrary text into a module path.

Code splitting: move optional bytes to later

STEP THROUGH

Dynamic import is language syntax. Code splitting is what build tools do with it: turn a module boundary into a separate file, often called a chunk. Instead of downloading an entire application before the first screen, the browser can download the shell now and feature code later. This very site is split per route by its bundler, so opening this lesson does not require the code for every other lesson.

Real-life analogyCode splitting is packing a suitcase by trip day

Carrying everything everywhere slows you down. Packing by trip day keeps the first walk light, while still letting you fetch the beach bag when beach day arrives.

In real life: Carry every outfit for a month
In JavaScript: One huge bundle loaded before anything runs
In real life: Pack day-one clothes in a small carry-on
In JavaScript: The shell and current route load first
In real life: Open another packing cube when plans change
In JavaScript: A feature chunk loads when the user opens it

Where the analogy stops: Suitcases are physical; downloaded code can be cached and reused. The analogy is about timing, not literal storage.

Code-splitting model: what loads now and later
Feature boundaryPop out in the code editor (opens in a new tab)JavaScript
const loaded = new Set(["shell"]);let bytes = 42; async function openFeature(name) {  if (!loaded.has(name)) {    bytes = bytes + featureBytes[name];    loaded.add(name);  }  return name + " ready";}
Bytes loaded42 KB
Shell route42 KB now
Charts0 KB so far
Editor0 KB so far
Try it yourself

The shell is paid up front. Charts and Editor are separate feature chunks, so their bytes move to later unless the learner opens them.

A model, not a network inspector. Real bundlers choose exact chunk names and sizes.

The next replay models the same idea as execution order. Try both settings. Loading on a click saves every byte until the user asks. Preloading on hover spends bytes earlier, but the eventual click may feel instant.

Replay a lazy feature load
Step 0 of 5Ready
Your turn: follow the blue line

Choose a loading strategy, then step through when bytes move from later to now.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
button.addEventListener("click", async () => {  const charts = await import("./charts.js");  charts.renderDashboard();});
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose when the Charts feature starts loading.

Changing the choice starts a fresh replay. Predict when the 120 KB chart chunk is paid.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Top-level await: a module can pause its own evaluation

INTERACTIVE

Top-level await means await directly inside a module, not inside an async function. It is allowed only in modules. When a module uses it, that module’s evaluation is pending until the awaited work settles. Modules that import it wait. Unrelated modules do not.

Real-life analogyA module with top-level await keeps the doors closed

If the chart module cannot export a ready chart until it has loaded a WebAssembly helper or configuration file, top-level await can express that one-time setup directly.

In real life: A shop finishes setup before unlocking the front door
In JavaScript: A module awaits configuration before its exports are ready
In real life: Customers for that shop wait in line
In JavaScript: Importers that depend on the module wait
In real life: The bakery next door can still open
In JavaScript: Sibling modules with no dependency can continue

Where the analogy stops: A real shop may let people browse during setup. A module’s importers cannot run dependent code until evaluation finishes.

Top-level await lab: who waits?
Config, importer, siblingJavaScript
// config.jsconst started = performance.now();await new Promise((resolve) => setTimeout(resolve, 350));export const config = "ready after " + Math.round(performance.now() - started) + "ms"; // app.jsimport { config } from "config";post("app can run only after config: " + config); // sibling.jspost("sibling module ran without waiting for config");
Timelinereal modules
    Try it yourself

    The importer waits for config because it depends on it. The sibling module does not depend on config, so it can report before the timer finishes.

    The parent validates event.source and the lesson token before showing a message.

    Use top-level await carefully. It is excellent for module-level setup that every export truly needs. It is harmful when placed in a central utility imported by every route, because then a slow setup step can delay much more of the dependency graph than you intended.

    import.meta and import.meta.resolve()

    INTERACTIVE

    A module sometimes needs to know where it lives. import.meta is the host’s metadata object for the current module. The most common property is import.meta.url, the current module’s URL. Use it with new URL("./asset.svg", import.meta.url) when an asset sits next to the module. The URL objects lesson covers that constructor in depth.

    Real-life analogyimport.meta is the module’s name badge

    When a module needs a nearby helper, image, or worker script, guessing from the page URL is wrong. Ask from the module’s own badge instead.

    In real life: A conference badge says who you are and where you belong
    In JavaScript: import.meta.url says which URL this module has
    In real life: Asking the venue where Room B is from here
    In JavaScript: import.meta.resolve("./helper.js") asks where a specifier points

    Where the analogy stops: A badge does not open the room. import.meta.resolve() resolves a URL string; it does not load or evaluate that module.

    import.meta lab: a module's name badge
    Module self-locationJavaScript
    console.log(import.meta.url);console.log(import.meta.resolve("./helper.js"));console.log(import.meta.resolve("missing-package"));
    Module reportreal browser
      Try it yourself

      The module reports its own URL, resolves a nearby relative specifier, and shows that an unmapped bare package name is not magic.

      import.meta.resolve() is synchronous in current browsers and current Node. Resolving does not load the target.
      Synchronous resolve

      In current browsers and current Node, import.meta.resolve() returns a string synchronously. It can still throw, for example when a bare package name has no import map or package resolution rule in that environment.

      import defer and import source are proposals

      The TC39 proposals repository currently lists Deferring Module Evaluation and Source Phase Imports under Stage 3. Stage 3 means the committee is far along and wants implementation feedback. It does not mean the syntax is safe to paste into every browser today. Treat the following as display-only recognition code.

      Proposal shapes, not runnable lesson codeJavaScript
      // Display-only proposals, not runnable lesson codeimport defer * as charts from "./heavy-charts.js";import source parser from "./parser.wasm"; // Today: use import() or your bundler's documented features instead.

      import defer is about linking now but deferring evaluation until the namespace is used. import source belongs to source-phase imports, a proposal for working with module source values such as WebAssembly modules before normal evaluation. Without a tool or an experimental implementation, ordinary browsers should not be expected to run either form. Today’s portable tool is still import().

      Sort the need: static import, import(), or top-level await?

      INTERACTIVE
      Three similar-looking tools
      PatternWhen it runsWhat you getBest for
      Static importBefore the importing module evaluatesImported live bindingsRequired dependencies and tool-friendly graphs
      Dynamic import()When that expression runsA promise for the module namespace objectOptional features, routes, and computed specifiers
      Top-level awaitDuring module evaluationNo new value by itself; it pauses that moduleOne-time async setup before exports are ready

      Try the sorter. A good rule: if the first screen cannot work without it, static import it. If a user action or setting chooses it later, use import(). If a module’s own exports are unusable until setup finishes, consider top-level await.

      Choose the loading pattern
      • Always need formatPrice before rendering the route
      • Load an admin panel only after an Admin button click
      • Pick ./locales/${language}.js from a setting
      • Read startup config before this module's exports are usable
      • Share constants used by nearly every module
      • Initialize a WebAssembly helper before exporting compress
      Try it yourself
      0 of 6 correct

      Place each need under the pattern that best communicates timing and dependency.

      Choose a category for every card. You can change an answer at any time; Reset clears them all.

      Where you’ll use this

      Dynamic imports show up anywhere a feature is useful but not guaranteed. Route-level code splitting is the most common case: settings pages, admin tools, and dashboards should not slow the public landing page. Heavy widgets are another: charting libraries, rich text editors, maps, syntax highlighters, and media tools can load after a clear click.

      A realistic optional featurePop out in the code editor (opens in a new tab)JavaScript
      button.addEventListener("click", async () => {  button.disabled = true;  const { openEditor } = await import("./editor-panel.js");  openEditor(document.querySelector("#draft"));}); async function loadLocale(language) {  return import("./locales/" + language + ".js");}

      The first import has a fixed specifier and opens a feature. The second computes a specifier from a setting. In a bundler project, you would keep the possible language files in a known folder so the tool can create safe chunks.

      Common misconceptions

      The bug hiding in many dynamic importsPop out in the code editor (opens in a new tab)JavaScript
      const chartPromise = import("./charts.js");// chartPromise.render() is a bug: await the promise first.const charts = await chartPromise;charts.render();

      “import() gives me the exports immediately.”

      It gives a promise immediately. Await it before reading exports, or use .then().

      “Dynamic import always creates a new module instance.”

      Successful modules are cached by URL. Importing the same URL twice gives the same module instance.

      “Top-level await freezes the whole page.”

      It delays only modules in the dependency chain. Sibling modules can continue.

      “Code splitting is automatic for any function call.”

      Bundlers need a module boundary, often a dynamic import, to create a separate chunk.

      “Stage 3 proposal syntax is production browser syntax.”

      Stage 3 is promising, not universal support. Use documented tooling before relying on proposals.

      Practice: load code deliberately

      5 EXERCISES
      Exercise 1 · Warm-upPredict the first log

      What does the first console.log print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const pending = import("data:text/javascript,export const answer = 32 + 10");
      console.log(pending instanceof Promise);
      pending.then((module) => console.log(module.answer));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 2 · PracticeProve the module cache

        Run the program in a modern browser console or Node module. What are the three log lines?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const source = "export let count = 0; export function next(){ count += 1; return count; }";
        const url = "data:text/javascript," + encodeURIComponent(source);
        const first = await import(url);
        console.log(first.next());
        const second = await import(url);
        console.log(second.count);
        console.log(first === second);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 3 · PracticeFind the bug

          In the misconception snippet above, why is chartPromise.render() wrong? Type the pattern that should be used for optional features.

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 4 · ChallengePlan a dashboard split

            Imagine a dashboard with navigation, charts, a map, and a markdown editor. Write down which parts you would load up front and which parts you would import later.

              Exercise 5 · ChallengeFind a nearby asset from a module

              Write the two-line pattern for creating a module worker that lives next to the current module as editor-worker.js.

                Quiz: dynamic imports and waiting modules

                7 QUESTIONS
                Lesson quiz · 7 questionsScore: first tries count
                1. Question 1 of 7What does dynamic import return immediately?

                  Choose an answer to see the explanation.

                2. Question 2 of 7What does this data URL dynamic import log first?

                  Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                  const pending = import("data:text/javascript,export const answer = 32 + 10");
                  console.log(pending instanceof Promise);
                  pending.then((module) => console.log(module.answer));

                  Choose an answer to see the explanation.

                3. Question 3 of 7What happens when the same normalized module URL is imported twice?

                  Choose an answer to see the explanation.

                4. Question 4 of 7Which modules wait for a module that uses top-level await?

                  Choose an answer to see the explanation.

                5. Question 5 of 7What is import.meta.url?

                  Choose an answer to see the explanation.

                6. Question 6 of 7What does current import.meta.resolve() return in browsers and modern Node?

                  Choose an answer to see the explanation.

                7. Question 7 of 7What should this lesson's import defer and import source examples be treated as?

                  Choose an answer to see the explanation.

                Key takeaways

                • import() is an expression that returns a promise for the module namespace object.
                • The specifier can be computed at runtime, but bundlers may need a bounded pattern.
                • Successfully evaluated modules are cached per normalized URL.
                • Code splitting moves optional feature bytes from startup to a later user need.
                • Top-level await is module-only and delays dependent importers, not unrelated modules.
                • import.meta.url is the module’s URL; import.meta.resolve() synchronously resolves specifiers in current browsers and Node.
                • import defer and import source are proposal shapes to recognize, not plain browser assumptions today.

                Remember the one-liner.
                Static imports load what this module always needs; import() loads optional code when asked; top-level await lets a module finish setup before importers use it.

                Up next: Module resolution & import maps.

                CompleteFrontend Clear concepts. Working examples.