
Este post sale de un caso real. Analizando el código de un proyecto de cliente, me encontré con este comentario en la revisión de un PR:

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

La funcionalidad cargaba un diálogo bajo demanda cuando se seleccionaba un archivo. Para no mostrar código del cliente, lo he reducido a un ejemplo que reproduce el mismo patrón:

```js
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");
  }
}
```

El comentario suena razonable: si cada selección de archivo llama a `import()`, parece que cada selección descarga el módulo otra vez. La propuesta habitual en estos casos es guardar el módulo en una propiedad y cargarlo solo la primera vez.

Pero el navegador ya hace ese trabajo. **Con `import()` nativo, cada módulo se descarga y se evalúa una sola vez por documento**, lo llamemos las veces que lo llamemos. Lo que sí merecía atención en esa revisión estaba en otra parte del código: en el `catch`.

## El _module map_

Cada documento tiene un _module map_, un registro de los módulos que ya ha cargado. La especificación de HTML lo define con una clave de dos partes: **la URL resuelta del módulo y su tipo** (JavaScript, JSON o CSS).

Cuando llamamos a `import("./dialog.js")`:

1. El navegador resuelve el especificador a una URL absoluta, relativa al módulo o documento que hace la llamada.
2. Busca esa URL en el _module map_.
3. **Si no está**, la descarga, la analiza, la enlaza con sus dependencias, la evalúa y la guarda.
4. **Si ya está**, no hace ninguna petición ni vuelve a evaluar nada: resuelve con el módulo que ya tiene.
5. **Si está descargándose en ese momento**, la segunda llamada espera a la misma descarga en lugar de lanzar otra.

MDN lo resume en su guía de módulos: esta caché garantiza que un módulo nunca se ejecuta más de una vez, y las importaciones siguientes ni siquiera generan una petición HTTP.

Un detalle que ayuda a entender qué se reutiliza exactamente: **cada llamada a `import()` devuelve una promesa nueva, pero todas resuelven con el mismo objeto**, el _namespace object_ del módulo.

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

a === b; // false: cada llamada crea su propia promesa
(await a) === (await b); // true: es el mismo módulo, con el mismo estado
```

Así que lo que cuesta cada selección de archivo después de la primera es crear una promesa y resolverla en una microtarea. Nada que se vaya a notar al lado de lo que hace el propio diálogo.

## Probarlo en el navegador

En esta demo, cada clic en el primer botón hace lo mismo que `onFileSelected()` en el ejemplo: llama a `import()` con el mismo módulo. El módulo incrementa un contador cada vez que se evalúa y guarda un estado interno (cuántos diálogos se han abierto), y la demo cuenta las peticiones de red con Resource Timing.

<figure>
  <iframe
    id="dyn-import-demo"
    src="/demos/dynamic-import-module-map.html"
    width="100%"
    height="760"
    style="border: none; border-radius: 8px; display: block;"
    title="Demo interactiva: llamadas repetidas a import() dinámico, cache busting y reintento tras un fallo"
    loading="lazy"
  ></iframe>
</figure>
<script>
window.addEventListener('message', function (ev) {
  if (ev.data && typeof ev.data.dynImportH === 'number') {
    var f = document.getElementById('dyn-import-demo');
    if (f) f.style.height = (ev.data.dynImportH + 8) + 'px';
  }
});
</script>

Si pulsamos varias veces el primer botón, las llamadas suben pero las peticiones y las evaluaciones se quedan en una, y el contador de diálogos abiertos sigue sumando porque siempre es la misma instancia. El segundo botón muestra el caso contrario, que vemos a continuación.

## Cuándo el mismo módulo deja de ser el mismo

La clave del _module map_ es la URL, así que **dos URLs distintas son dos módulos distintos**, aunque apunten al mismo archivo. El caso más habitual es el _cache busting_ con un parámetro en la URL:

```js
// ❌ Bad: cada llamada es otra URL, otro módulo, otra descarga y otro estado
const dialog = await import(`./dialog.js?v=${Date.now()}`);

// ✅ Good: la misma URL reutiliza el módulo ya cargado
const dialog = await import("./dialog.js");
```

Con el parámetro, cada llamada descarga y evalúa el módulo de nuevo, y cada instancia tiene su propio estado. Si el módulo registra _listeners_ al evaluarse o guarda algo a nivel de módulo, lo tendremos duplicado. En la demo se ve en el contador de diálogos abiertos: cada instancia nueva empieza desde uno.

MDN documenta precisamente este truco como la única forma de forzar una segunda evaluación, porque no existe una API para vaciar el _module map_. Tiene sentido en un entorno de desarrollo; en una interacción en producción casi nunca es lo que queremos.

Pasa lo mismo si el mismo archivo se importa desde URLs distintas, por ejemplo desde nuestro origen y desde un CDN: para el navegador son dos módulos.

## El `catch`: lo que sí merecía atención

Volvamos al código del principio. El `try/catch` contempla que la carga falle: una red inestable, o un deploy que ha sustituido el archivo por otro con un hash distinto y ahora la URL antigua da un 404.

Durante años, la especificación de HTML guardaba también los fallos en el _module map_. **Si la primera descarga fallaba, cualquier `import()` posterior de la misma URL fallaba al momento sin volver a salir a la red**, hasta recargar la página. Con el código del ejemplo, un fallo puntual en la primera selección deja la funcionalidad rota durante toda la sesión: cada selección siguiente acaba en el `console.error` sin reintentar.

Eso ha cambiado. En julio de 2026 se fusionó en la especificación de HTML el cambio [_Don't cache HTTP errors in the module map_](https://github.com/whatwg/html/pull/10327), que cerró una discusión abierta desde 2021. Ahora los errores de red y de HTTP ya no se guardan: el siguiente `import()` vuelve a intentar la descarga. Así está cada navegador en octubre de 2026:

| Navegador     | Reintenta tras un fallo de descarga                                           |
| ------------- | ----------------------------------------------------------------------------- |
| Firefox       | 155                                                                           |
| Safari        | Integrado en WebKit en agosto de 2026, sin versión estable confirmada todavía |
| Chrome y Edge | Previsto para Chrome 156, en estado _Proposed_ en Chrome Platform Status      |

La segunda parte de la demo lo comprueba en el navegador en el que la estemos viendo: importa un módulo que no existe dos veces y mira si el segundo intento sale a la red. Probándola con Chromium 145 y con WebKit 26, el segundo intento falla sin hacer ninguna petición.

Hay un matiz que el cambio no toca: **los errores de evaluación se siguen guardando**. Si el módulo se descarga bien pero lanza una excepción al ejecutarse, queda marcado como fallido y cualquier `import()` posterior devuelve el mismo error sin volver a ejecutarlo. El cambio solo afecta a los fallos de descarga.

### Un reintento que funcione en todos los navegadores

Mientras convivan los dos comportamientos, si queremos que un fallo puntual no deje la funcionalidad rota hasta recargar, necesitamos una URL distinta para el reintento. Y aquí es donde guardar una referencia sí tiene sentido: no para evitar descargas, que ya evita el navegador, sino para que, después de reintentar con otra URL, el resto de la sesión use esa misma instancia.

Guardamos la promesa, no el módulo, para que las selecciones que lleguen mientras la carga está en curso compartan el mismo intento:

```js
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;
  }
}
```

Si el reintento también falla, la excepción llega a quien llama, el `catch` de `onFileSelected()` la gestiona como hasta ahora y la siguiente selección vuelve a intentarlo. Cuando todos los navegadores dejen de cachear los fallos, bastará con reintentar la misma URL.

## ¿Y con un bundler?

La garantía de "una sola vez" se mantiene, aunque la da otra pieza:

- **webpack** convierte `import()` en una carga de chunks gestionada por su propio runtime, que lleva un registro de los chunks ya instalados y reutiliza la promesa si el chunk se está descargando.
- **Vite** mantiene en producción el `import()` nativo, envuelto en su función de precarga (`__vitePreload`) para descargar en paralelo las dependencias del chunk. La caché vuelve a ser el _module map_ del navegador.

El reintento con otra URL que hemos visto antes es para módulos nativos. Con un bundler, el especificador de `import()` lo reescribe el propio bundler y una URL construida en tiempo de ejecución no apunta a ningún chunk, así que el reintento hay que resolverlo con las herramientas de cada uno.

## Compatibilidad

El `import()` dinámico está disponible en todos los navegadores modernos desde hace años:

| Navegador     | Desde la versión |
| ------------- | ---------------- |
| Chrome        | 63               |
| Edge          | 79               |
| Firefox       | 67               |
| Safari        | 11.1             |
| Safari en iOS | 11               |

El comportamiento del _module map_ que describe este post (una descarga y una evaluación por URL) es el de siempre. Lo único que está cambiando es cómo se tratan los fallos de descarga, que hemos visto en la tabla anterior.

## Conclusión

Volviendo al comentario del PR: el módulo ya se carga una sola vez. Llamar a `import()` en cada selección de archivo no repite la descarga ni la evaluación; el navegador lo garantiza con el _module map_.

Lo que conviene revisar cuando vemos un `import()` dinámico es otra cosa:

- **Que la URL sea estable**: un parámetro variable convierte cada llamada en un módulo nuevo, con su descarga y su estado.
- **Qué pasa cuando la carga falla**: hasta que todos los navegadores dejen de cachear los fallos de descarga, un error puntual puede dejar la funcionalidad rota durante toda la sesión si no hay un reintento con otra URL.
- **Que los errores de evaluación no se reintentan**: si el módulo lanza una excepción al ejecutarse, ese fallo se queda guardado.

## Referencias

- [Module map](https://html.spec.whatwg.org/multipage/webappapis.html#module-map), HTML Standard
- [import()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) y [JavaScript modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), MDN
- [Dynamic import()](https://v8.dev/features/dynamic-import), V8
- [Don't cache HTTP errors in the module map](https://github.com/whatwg/html/pull/10327), el cambio en la especificación de HTML, y la [discusión original](https://github.com/whatwg/html/issues/6768)
- [Bug 2055211](https://bugzilla.mozilla.org/show_bug.cgi?id=2055211) de Firefox y [bug 319492](https://bugs.webkit.org/show_bug.cgi?id=319492) de WebKit
- [Avoid caching module failures](https://chromestatus.com/feature/5214647044145152), Chrome Platform Status
- [Dynamic import()](https://caniuse.com/es6-module-dynamic-import), Can I use
