v156 · javascript · modules
Avoid caching module failures
Until now, a module fetch that failed — a dropped connection, a 404, a server hiccup — was cached in the module map forever: every later import() of the same URL rejected instantly without touching the network, for the whole life of the page. Chrome 156 removes failed fetches from the module map, so calling import() again actually retries. There is no new API; the change is that retries finally reach the network.
concepts
-
Retry refetch probe
The exact observable contract: import the same failing URL twice and count the real network attempts in the Resource Timing buffer. One entry after two imports means the failure was served from the module map cache (pre-156); two entries mean the browser refetched (the 156 behaviour). The verdict is read from
performance.getEntriesByType("resource"), not from a version sniff. -
Flaky network recovery
The developer pain this fixes: a module fails to load on an unstable connection and a retry button recovers it. A service worker plays the unreliable network — it drops the first fetch and serves the module on the next — so on Chrome 156 the retry genuinely reaches the network layer and the widget mounts, while on older Chrome the worker never sees attempt #2 and the page shows exactly why.
-
What stays cached
The spec change is precise: network and HTTP-status failures become retryable, but a module that parses badly is still cached, and successes were always cached. Three modules — a missing one, a served-but-broken one, and a healthy one — each imported twice, with the fetch counts to prove which category refetches.
why it shipped
Single-page apps import code on demand, and mobile networks drop requests. Before this change, one transient failure poisoned that module URL for the whole session: the HTML Standard's "fetch a single module script" stored a permanent null entry in the module map, so import() retries rejected from cache without a network request. The only workarounds were a full page reload or cache-busting query strings (import(url + "?retry=1")) — which then double-load the module once the network recovers, because each URL variant is a separate module map entry.
The spec change (whatwg/html#10327, driven by developers hitting this at scale) removes the module map entry when the fetch fails with a network error or a non-ok status — but keeps it when the module was fetched fine and failed to parse, since refetching identical broken source would not help. Firefox and Safari ship the same behaviour. The failure itself still rejects; what changes is that a deliberate retry is now allowed to succeed.
the API
// There is no new syntax — import() simply becomes retryable.
async function importWithRetry(url, attempts = 3) {
for (let i = 1; i <= attempts; i++) {
try {
return await import(url);
} catch (error) {
if (i === attempts) throw error;
// Pre-156: this retry rejects instantly from the module map cache.
// Chrome 156+: this retry issues a real network fetch.
await new Promise((r) => setTimeout(r, 250 * 2 ** i));
}
}
}
research notes
Behaviour grounded in the merged "fetch a single module script" algorithm (module map entries are now callback lists while fetching, and are removed when bodyBytes is null/failure or the response status is not ok), the explainer by Yoav Weiss, and live probing: on this project's Chromium 141 baseline a second import() of a 404 URL adds no Resource Timing entry (cached failure), which is the pre-156 behaviour these demos detect. No runtime flag: the change rides the default module loader (Blink>HTML>Modules, tracking bug 534781954). Use-case portfolio: exact-contract probe (basic + inspection), retry/recovery product flow (practical), failure-category matrix (edge/spec nuance); no new API surface exists to compose with, so a separate "advanced composition" page would duplicate the retry-helper pattern already shown.