El module map: el caché que te rompía los imports (y ahora también guarda texto)

Si tenés una aplicación con code splitting, es muy probable que este error esté en tu herramienta de tracking:
TypeError: Failed to fetch dynamically imported module: https://app.example.com/assets/settings-4f3a2c.js
Que aparezca no tiene nada de raro. Hay usuarios en el subte, en el wifi de un aeropuerto o con 4G que va y viene. Lo raro es otra cosa: el mismo usuario lo reporta 40, 80, 90 veces seguidas. Toca el botón de nuevo y falla. Cambia de pestaña, vuelve, ya tiene señal, y falla igual. Hasta que recarga la página, y ahí anda todo.
El primer reflejo es echarle la culpa al bundler, pero el problema está en el navegador, en el module map. Es una estructura que casi nunca miramos y que en los últimos dos releases de Chrome cambió por dos lados: dejó de guardar los fallos y empezó a guardar texto.
Qué es el module map
Cada documento, y cada worker, tiene un mapa de módulos. La key es la URL del módulo junto con su tipo (JavaScript, JSON, CSS y ahora texto), y el valor es el módulo ya parseado.
Es lo que garantiza algo que damos por sentado: si quince módulos importan ./store.js, existe una sola instancia de ese store. Cuando hacés import(), lo primero que mira el navegador es este mapa. Si hay una entrada, no toca la red.
Y ojo, que no es el HTTP cache. No expira, no respeta Cache-Control, no se puede limpiar desde JavaScript y vive lo mismo que la página. Para el module map, una URL que ya se resolvió queda resuelta para siempre.
Y ahí estaba el problema. Hasta este año, la spec de HTML decía que si el fetch de un módulo fallaba (error de red, un 404, un 500, un MIME type incorrecto) había que guardar null en esa entrada, y ahí quedaba. Cualquier import() posterior de esa URL fallaba al instante, sin hacer ningún request. El usuario podía tener la mejor conexión del mundo: ese módulo estaba muerto hasta recargar.
Cinco años de issue
El reclamo formal arrancó en 2021 con el issue whatwg/html#6768, abierto por alguien del equipo de Vue CLI. Firefox había recibido un reporte parecido y lo cerró porque era lo que decía la spec.
La parte de ECMAScript ya estaba resuelta desde 2019 (ecma262#1645), cuando se permitió reintentar un import() dinámico que había fallado. Faltaba que HTML dejara de cachear el fallo, y eso tardó bastante más.
Safari nunca siguió la spec en esto: no cacheaba los fallos. Gracias a eso hay un dato muy bueno en el issue. En mayo de 2024 alguien compartió números de producción de un sitio con unos 750 módulos, un 10% cargados con import():
| Navegador | Usuarios con algún error | Errores promedio por usuario afectado |
|---|---|---|
| Chrome Android | 0,5% | 90 |
| Safari iPhone | 1,3% | 4,2 |
| Firefox | 2,1% | 160 |
Safari tenía más usuarios afectados que Chrome Android, pero se recuperaban: cuatro errores por usuario, contra 90 y 160. Por cada mil usuarios, Safari registraba 52 errores, Chrome 459 y Firefox 3340. En Chrome y Firefox el usuario quedaba en un loop del que no salía sin recargar. Es un solo sitio en un momento puntual, así que no lo tomaría como estadística general.
El cambio de spec llegó con el PR whatwg/html#10327, que abrió Luca Casonato (de Deno) en 2024 y se mergeó recién en julio de 2026, cuando Yoav Weiss lo empujó con Shopify como caso de uso. Chrome lo envía en la versión 156 y Firefox en la 155.
Qué se reintenta y qué no
Lo que sale del module map es el fallo de fetch. Los demás errores se siguen guardando:
| Qué falló | ¿Se puede reintentar? |
|---|---|
| Red, CORS o CSP | Sí |
| Respuesta 4xx o 5xx | Sí (para los 4xx hay una discusión abierta para volver a cachear algunos) |
| MIME type incorrecto | Sí |
SyntaxError al parsear |
No, queda cacheado |
| Excepción al evaluar el módulo | No, devuelve el mismo objeto de error |
Los dos últimos tienen sentido. Si el módulo bajó bien pero tiene un error de sintaxis, pedirlo de nuevo no lo va a arreglar (salvo que hayas deployado justo en el medio, y ahí el problema es otro). Y los errores de evaluación vienen de ECMAScript: un módulo que tiró una excepción queda en ese estado, y un nuevo import() te devuelve el mismo objeto de error, sin volver a ejecutar nada.
Otro detalle: si hacés dos import() del mismo módulo al mismo tiempo, comparten el request y fallan juntos. El reintento tiene que ser posterior.
Las dependencias transitivas
Este es el caso que más me gusta, porque es justo el que ningún workaround resolvía bien:
// routes/settings.js baja bien, pero su dependencia estática falla
import { chart } from '../lib/chart.js'; // 503 en el primer intento
// En la app:
await import('./routes/settings.js'); // falla por chart.js
await import('./routes/settings.js'); // ahora: solo vuelve a pedir chart.js
En el segundo intento, settings.js no se vuelve a pedir porque ya está bien en el module map. Solo se pide chart.js. Lo probé con un server de Node que devuelve un 503 la primera vez: settings.js se pidió una sola vez y chart.js, dos.
Hasta ahora el truco era el cache busting: import(`./routes/settings.js?retry=${n}`). Tiene dos problemas. Primero, no arregla la dependencia: settings.js?retry=1 vuelve a importar ../lib/chart.js con la misma URL de siempre, que sigue marcada como fallida. Segundo, y peor, una URL distinta es una entrada distinta en el module map, o sea otra instancia del módulo. Si ese módulo tenía estado, ahora hay dos stores, dos contextos, y si registraba un custom element, el segundo customElements.define() tira NotSupportedError.
El HTTP cache sigue ahí
Que el module map ya no guarde el fallo no significa que el reintento vaya a la red. Si el 404 o el 503 vino con headers cacheables, el import() siguiente lo va a sacar del HTTP cache y va a fallar de nuevo, sin tocar el servidor.
Este blog tuvo una versión bastante más extrema de esto. En el firebase.json del blog, /assets/** tiene max-age de 30 días, Firebase aplica ese header sin mirar el status, y Cloudflare guarda esa respuesta en el edge. Un solo pedido a una imagen que todavía no se había subido dejó un 404 cacheado por un mes, y solo se arreglaba purgando Cloudflare. Eso no lo arregla ningún cambio del navegador.
Así que, antes que nada, las respuestas de error de tus assets no tienen que ser cacheables. Para el cache local del navegador hay un truco: hacer fetch(url, { cache: 'reload' }) antes de reintentar refresca la entrada del HTTP cache, y el import() siguiente usa la respuesta nueva. Ojo, que solo refresca la URL que le pasás: si lo que quedó cacheado es el error de una dependencia, no te salva. Y a un CDN que ya cacheó el error tampoco lo arregla.
Un helper de retry
Con todo esto, el helper que uso queda así:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const waitOnline = (timeout = 5000) =>
navigator.onLine
? Promise.resolve()
: Promise.race([
new Promise((r) => addEventListener('online', r, { once: true })),
sleep(timeout), // sin esto, offline la promesa queda colgada para siempre
]);
export async function importWithRetry(load, { retries = 3, baseDelay = 300, url } = {}) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await load();
} catch (err) {
// Parse o evaluación: el module map devuelve el MISMO error. No tiene sentido insistir.
if (err === lastError || !(err instanceof TypeError)) throw err;
lastError = err;
if (attempt === retries) break;
await waitOnline();
// Si el error quedó en el HTTP cache, lo refrescamos antes del próximo intento
if (url) await fetch(url, { cache: 'reload' }).catch(() => {});
await sleep(baseDelay * 2 ** attempt * (0.5 + Math.random()));
}
}
throw lastError;
}
La comparación err === lastError cubre un caso borde: un error de evaluación que justo es un TypeError. Como el module map te devuelve el mismo objeto, si lo vemos dos veces sabemos que no es un problema de red.
Con React hay que tener un cuidado extra: React.lazy() cachea la promesa rechazada en el componente. Si el primer intento falla y React lo ve, ese componente lazy queda roto. Por eso el retry tiene que vivir adentro de la factory:
const Settings = lazy(() => importWithRetry(() => import('./routes/settings.js')));
¿Y cómo detecto si el navegador tiene el comportamiento nuevo? No hay una forma limpia, porque no hay una API nueva para preguntar. Yo no detectaría nada: en un navegador viejo, cada reintento falla al instante sin hacer request, terminás en el throw y caés en lo que sea que hagas cuando falla (el reload de la sección siguiente). El costo es uno o dos segundos extra antes del fallback.
Cuando el problema no es la red
Hay un caso en el que el retry no sirve: el usuario tiene la versión de ayer de tu app abierta, hiciste un deploy, y el chunk settings-4f3a2c.js ya no existe. Podés reintentar mil veces: es un 404 legítimo.
Ahí lo que corresponde es recargar, con cuidado de no entrar en un loop:
export async function loadRoute(load, url) {
try {
return await importWithRetry(load, { url });
} catch (err) {
const res = await fetch(url, { method: 'HEAD', cache: 'no-store' }).catch(() => null);
if (res && res.status === 404) {
// Estamos corriendo una versión vieja de la app
const key = `reloaded:${url}`;
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, '1');
location.reload();
return new Promise(() => {}); // la página se va
}
}
throw err; // red caída de verdad: UI de error con un botón de reintentar
}
}
Fijate que el chequeo es por 404. Si tu hosting tiene fallback de SPA (el clásico rewrite de todo a index.html), un chunk borrado vuelve como 200 con HTML, el import falla por MIME y este chequeo no lo agarra. S3 con CloudFront, además, devuelve 403. Ahí conviene mirar si el content-type es JavaScript, o comparar contra un version.json que publiques en cada deploy. Y url es la URL real del chunk, con hash, que tu código fuente no conoce: Chrome la incluye en el mensaje del error, pero eso no es portable, así que en la práctica sale del manifest del bundler.
Para deploys, de todos modos, la solución de fondo no está en el cliente: no borres los assets con hash de los deploys anteriores apenas subís uno nuevo, y serví el HTML con Cache-Control: no-cache para que nadie quede anclado a un index.html viejo.
Un comentario sobre bundlers. El cambio beneficia a todo lo que use import() nativo para el code splitting: Vite, Rollup, esbuild o import maps sin bundler. La documentación de Vite, de hecho, ya menciona que en navegadores con reintento de módulos podés volver a llamar el import original desde el handler de vite:preloadError. webpack, en cambio, por default carga sus chunks con su propio runtime (por eso su error es ChunkLoadError), así que este cambio no lo toca y los plugins de retry siguen siendo necesarios. Distinto es si usás su salida ESM (output.module), que sí usa import() nativo.
Y ahora el module map también guarda texto
La otra novedad, de Chrome 155, es más simple de explicar:
import systemPrompt from './prompts/summarize.md' with { type: 'text' };
import shaderCode from './shaders/blur.wgsl' with { type: 'text' };
const module = device.createShaderModule({ code: shaderCode });
export function buildRequest(article) {
return {
system: systemPrompt,
messages: [{ role: 'user', content: article }],
};
}
Es la propuesta Import Text de TC39, que está en Stage 3. El módulo exporta un único default con el contenido del archivo como string. Prompts de LLM versionados como .md al lado del código (con diffs que se leen bien en un PR), shaders, templates HTML, SVG: todo lo que hoy resolvés con el ?raw de Vite o un loader de webpack.
Se parece bastante a un fetch().text() con otra sintaxis, pero hay diferencias:
- Es estático. El navegador lo descubre cuando arma el grafo de módulos y lo pide en paralelo con todo lo demás. Con
fetchhay un waterfall: primero baja y se ejecuta tu módulo, después arranca el request. - Resuelve relativo al módulo.
fetch('./x.txt')resuelve contra la URL del documento, no del archivo donde está escrito. Es un bug clásico cuando movés código entre carpetas. - No necesitás
awaiten el lugar donde lo usás.
Y como entra al module map, se puede precargar igual que cualquier módulo:
<link rel="modulepreload" as="text" href="/app/prompts/summarize.md">
Los detalles
Lo que hay que tener en cuenta:
- No valida el MIME type. JSON modules exigen un MIME de JSON y CSS modules exigen
text/css. Text modules acepta cualquier cosa, y es a propósito. El problema es que si un proxy te devuelve un 200 con una página HTML de error, la vas a recibir como un string perfectamente válido. Un 4xx o 5xx sí falla. - Siempre decodifica como UTF-8. El
charsetdelContent-Typese ignora, el BOM se descarta y los bytes inválidos se convierten en�. Si tenés archivos en Latin-1 por ahí, este no es el camino. Para eso está la propuesta hermana, Import Bytes (type: 'bytes', que devuelve unUint8Array), en Stage 2.7. - La CSP que lo controla es
connect-src, como con JSON, y noscript-src. Y si es cross-origin necesita CORS como cualquier módulo. - La key incluye el tipo. El mismo archivo importado como
texty comojsonson dos entradas distintas y dos requests. - El string vive toda la página. Igual que cualquier módulo, no se puede liberar. Si importás un archivo de 5 MB, son 5 MB que quedan en memoria hasta que el usuario se vaya.
Usarlo hoy
Firefox lo soporta desde la 153, Chrome desde la 155 y Safari todavía no. Con Safari hay que tener cuidado, porque un import estático con un tipo que el navegador no conoce hace fallar al módulo que lo contiene, y con él todo lo que dependa de ese módulo. En Safari, una sola línea puede romperte la app entera.
Hasta que Safari lo tenga, si no pasa por un bundler, conviene un wrapper con import() dinámico:
export async function importText(specifier, base) {
const url = new URL(specifier, base);
try {
return (await import(url.href, { with: { type: 'text' } })).default;
} catch (err) {
if (!(err instanceof TypeError)) throw err;
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} al pedir ${url}`);
return res.text();
}
}
const template = await importText('./card.html', import.meta.url);
El import.meta.url lo tiene que pasar quien llama. La tentación es ponerlo como valor por default en el helper, pero ahí import.meta.url es la URL del archivo del helper, y ./card.html se resolvería contra esa carpeta.
El catch también atrapa un error de red en navegadores con soporte, y ahí el fetch termina funcionando como un segundo intento. No es lo más prolijo, pero para esto me alcanza.
En tooling el panorama está bastante avanzado: esbuild (0.28), webpack (5.102), Rspack (1.7), Deno (2.8, sin flag) y Bun ya lo entienden; Node lo tiene detrás de --experimental-import-text; Vite todavía no. Del lado de TypeScript, la 7.1 (en beta) permite por fin tipar módulos según el atributo:
declare module '*' with { type: 'text' } {
const content: string;
export default content;
}
Hasta ahora lo único que había era declare module '*.txt', que mira la extensión y no el atributo.
Conclusión
De las dos novedades, la que más me importa es el reintento. Los text modules están buenos y van a ahorrar unos cuantos loaders, pero con Safari afuera todavía son algo para usar con cuidado. El reintento, en cambio, arregla un problema que tus usuarios ya están sufriendo, y para aprovecharlo alcanza con volver a llamar al import(), sin cache busting ni recargas.
Si tengo que dejar un checklist:
- Mirá en tu tracking cuántas veces por usuario aparece "Failed to fetch dynamically imported module". Si es más de una o dos, probablemente estabas pagando el costo del module map.
- Cuando tu base de navegadores lo permita (Firefox ESR y los Chrome corporativos atrasados todavía no lo tienen), sacá el cache busting con
?retry=: en los navegadores nuevos solo te duplica instancias. - Poné el retry adentro de la factory del
lazy(), no afuera. - Asegurate de que los errores de tus assets no sean cacheables, ni en el navegador ni en el CDN.
- No borres los chunks del deploy anterior, y tené un reload con guarda para cuando igual pase.
Si después de leer esto vas al tracking y ves 90 errores por usuario, ya sabés de dónde vienen.

