Skip to content

Dynamic import(): the browser already caches our modules

Published:

This post comes from a real case. While analyzing the code of a client project, I came across this comment in a PR review:

The module should only be loaded once, not every time a file is selected.

The feature loaded a dialog on demand when a file was selected. To avoid showing the client’s code, I’ve reduced it to an example that reproduces the same pattern:

class FileUploader {
  async onFileSelected(file) {
    let dialogModule;
    try {
      dialogModule = await this.loadDialogModule();
    } catch {
      console.error("Failed to load the feature");
      return;
    }
    dialogModule.openDialog(file);
  }

  loadDialogModule() {
    return import("./dialog.js");
  }
}

The comment sounds reasonable: if every file selection calls import(), it looks like every selection downloads the module again. The usual proposal in these cases is to store the module in a property and load it only the first time.

But the browser already does that work. With native import(), each module is downloaded and evaluated only once per document, however many times we call it. What did deserve attention in that review was somewhere else in the code: the catch.

The module map

Every document has a module map, a record of the modules it has already loaded. The HTML specification defines it with a two-part key: the module’s resolved URL and its type (JavaScript, JSON or CSS).

When we call import("./dialog.js"):

  1. The browser resolves the specifier to an absolute URL, relative to the module or document making the call.
  2. It looks up that URL in the module map.
  3. If it isn’t there, it downloads it, parses it, links it with its dependencies, evaluates it and stores it.
  4. If it’s already there, it makes no request and evaluates nothing again: it resolves with the module it already has.
  5. If it’s being downloaded at that moment, the second call waits for the same download instead of starting another one.

MDN sums it up in its modules guide: this cache ensures a module is never executed more than once, and later imports don’t even trigger an HTTP request.

One detail helps to understand what exactly gets reused: each import() call returns a new promise, but they all resolve with the same object, the module’s namespace object.

const a = import("./dialog.js");
const b = import("./dialog.js");

a === b; // false: each call creates its own promise
(await a) === (await b); // true: same module, same state

So what each file selection costs after the first one is creating a promise and resolving it in a microtask. We won’t notice it next to what the dialog itself does.

Trying it in the browser

In this demo, each click on the first button does the same as onFileSelected() in the example: it calls import() with the same module. The module increments a counter every time it’s evaluated and keeps some internal state (how many dialogs have been opened), and the demo counts network requests with Resource Timing.

If we click the first button several times, the calls go up but requests and evaluations stay at one, and the opened-dialogs counter keeps adding up because it’s always the same instance. The second button shows the opposite case, which we look at next.

When the same module stops being the same

The module map key is the URL, so two different URLs are two different modules, even if they point to the same file. The most common case is cache busting with a query parameter:

// ❌ Bad: every call is another URL, another module, another download and another state
const dialog = await import(`./dialog.js?v=${Date.now()}`);

// ✅ Good: the same URL reuses the module already loaded
const dialog = await import("./dialog.js");

With the parameter, every call downloads and evaluates the module again, and each instance has its own state. If the module registers listeners when it’s evaluated, or keeps anything at module level, we end up with duplicates. The demo shows it in the opened-dialogs counter: each new instance starts from one.

MDN documents this trick as the only way to force a second evaluation, because there’s no API to clear the module map. It makes sense in a development environment; in a production interaction it’s almost never what we want.

The same happens if the same file is imported from different URLs, for example from our own origin and from a CDN: for the browser they’re two modules.

The catch: what did deserve attention

Let’s go back to the code at the start. The try/catch accounts for the load failing: a flaky network, or a deploy that replaced the file with one that has a different hash, so the old URL now returns a 404.

For years, the HTML specification also stored failures in the module map. If the first download failed, any later import() of the same URL failed immediately without going back to the network, until the page was reloaded. With the example code, a one-off failure on the first selection leaves the feature broken for the whole session: every later selection ends up in the console.error without retrying.

That has changed. In July 2026 the change Don’t cache HTTP errors in the module map was merged into the HTML specification, closing a discussion open since 2021. Network and HTTP errors are no longer stored: the next import() tries the download again. This is where each browser stands in October 2026:

BrowserRetries after a failed download
Firefox155
SafariLanded in WebKit in August 2026, no stable version confirmed yet
Chrome and EdgePlanned for Chrome 156, with Proposed status in Chrome Platform Status

The second part of the demo checks it in whichever browser we’re viewing it in: it imports a module that doesn’t exist twice and checks whether the second attempt goes to the network. Tested with Chromium 145 and WebKit 26, the second attempt fails without making any request.

There’s one thing the change doesn’t touch: evaluation errors are still stored. If the module downloads fine but throws an exception when it runs, it’s marked as failed, and any later import() returns the same error without running it again. The change only affects download failures.

A retry that works in every browser

While both behaviors coexist, if we don’t want a one-off failure to leave the feature broken until a reload, we need a different URL for the retry. And this is where keeping a reference does make sense: not to avoid downloads, which the browser already avoids, but so that after retrying with another URL, the rest of the session uses that same instance.

We store the promise, not the module, so that selections arriving while the load is in progress share the same attempt:

class FileUploader {
  #dialogModulePromise;

  loadDialogModule() {
    this.#dialogModulePromise ??= import("./dialog.js")
      // Some browsers still cache the failed fetch: a different URL forces a new request
      .catch(() => import(`./dialog.js?retry=${Date.now()}`))
      .catch(error => {
        // Forget the failure so the next selection tries again
        this.#dialogModulePromise = undefined;
        throw error;
      });
    return this.#dialogModulePromise;
  }
}

If the retry fails too, the exception reaches the caller, the catch in onFileSelected() handles it as before, and the next selection tries again. Once every browser stops caching failures, retrying the same URL will be enough.

What about bundlers?

The “only once” guarantee still holds, but a different piece provides it:

The retry with a different URL that we saw earlier is for native modules. With a bundler, the bundler itself rewrites the import() specifier, and a URL built at runtime doesn’t point to any chunk, so the retry has to be solved with each bundler’s own tools.

Browser support

Dynamic import() has been available in every modern browser for years:

BrowserSince version
Chrome63
Edge79
Firefox67
Safari11.1
Safari on iOS11

The module map behavior described in this post (one download and one evaluation per URL) has always been there. The only thing changing is how download failures are handled, which we saw in the previous table.

Conclusion

Back to the PR comment: the module is already loaded only once. Calling import() on every file selection doesn’t repeat the download or the evaluation; the browser guarantees it with the module map.

What’s worth reviewing when we see a dynamic import() is something else:

References


Next Post
WebMCP on a static site: the GeeksCAT Conf 2026 website, ready for agents