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:
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"):
- El navegador resuelve el especificador a una URL absoluta, relativa al módulo o documento que hace la llamada.
- Busca esa URL en el module map.
- Si no está, la descarga, la analiza, la enlaza con sus dependencias, la evalúa y la guarda.
- Si ya está, no hace ninguna petición ni vuelve a evaluar nada: resuelve con el módulo que ya tiene.
- 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.
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.
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:
// ❌ 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, 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:
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, HTML Standard
- import() y JavaScript modules, MDN
- Dynamic import(), V8
- Don’t cache HTTP errors in the module map, el cambio en la especificación de HTML, y la discusión original
- Bug 2055211 de Firefox y bug 319492 de WebKit
- Avoid caching module failures, Chrome Platform Status
- Dynamic import(), Can I use