~10 min de lecture

Failed to fetch dynamically imported module : le crash que tes déploiements livrent

TL;DR

Ton app charge ses routes en lazy loading, ton build de prod hashe les noms de fichiers, ton hébergeur remplace tout à chaque déploiement. Résultat : toute mise en prod qui modifie un chunk lazy piège les onglets déjà ouverts, et le premier clic vers une route pas encore chargée explose avec TypeError: Failed to fetch dynamically imported module. Réessayer le même import() est sans espoir : le fichier a disparu du serveur, et Chromium ne refait même pas la requête. La seule sortie est un rechargement complet, que tu peux automatiser avec withNavigationErrorHandler ; pour les sessions très longues, le service worker Angular traite la cause. Mesuré sur Angular 22.1.6 et Chromium ; toutes les API utilisées sont disponibles dès la v17, le plancher visé ici.


Lundi matin, Sentry a passé un mauvais week-end

Tu as déployé vendredi en fin de journée. Une release banale : deux fixes, un feature flag. Les tests passent, la prod répond, tu fermes ton laptop.

Lundi matin, ton tracker d'erreurs affiche quelques dizaines d'occurrences de la même erreur :

TypeError: Failed to fetch dynamically imported module: https://app.example.com/chunk-K7KKQNGB.js

Tu cliques sur l'URL du chunk : 404. Tu ouvres l'app : tout marche. Tu testes la route incriminée : elle marche. Impossible à reproduire. Tu classes l'affaire dans les mystères du web.

Sauf que l'erreur n'a rien de mystérieux, et surtout : elle reviendra à chaque déploiement qui touche un chunk lazy, en frappant précisément les utilisateurs les plus engagés, ceux qui gardent ton app ouverte dans un onglet toute la journée.

Trois mécanismes qui s'emboîtent

Aucun des trois n'est un bug. C'est leur combinaison qui casse.

1. Le build de prod hashe les noms de fichiers. La configuration production posée par ng new contient outputHashing: "all" : chaque bundle sort avec un hash de contenu dans son nom, du genre chunk-K7KKQNGB.js. C'est voulu, c'est ce qui permet un cache immuable. Corollaire : un chunk dont le contenu a changé sort sous un autre nom et l'ancien fichier disparaît du build suivant, tandis qu'un chunk inchangé garde exactement le même nom. C'est ce qui rend le piège intermittent, donc difficile à diagnostiquer.

2. L'onglet ouvert vit dans le passé. Un utilisateur qui a chargé ton app jeudi a en mémoire le main.js de jeudi, qui référence les noms de chunks de jeudi. Tant qu'il ne recharge pas la page, son application est figée sur cette version. Les routes en loadComponent() et les blocs @defer qu'il n'a pas encore déclenchés ne sont que des promesses de téléchargement, avec des URLs de jeudi.

3. Le déploiement est atomique. Sur les plateformes comme Vercel, Netlify ou Cloudflare Pages, la nouvelle version remplace l'ancienne d'un bloc : les fichiers de jeudi ne sont plus servis. (Un bucket synchronisé sans purge garde les vieux fichiers et repousse l'échéance, jusqu'au premier nettoyage.) L'onglet de jeudi demande chunk-K7KKQNGB.js, le CDN répond 404, et l'import() de ta route rejette.

La fenêtre d'exposition, c'est toute session ouverte avant le déploiement qui navigue après. Plus tes utilisateurs gardent l'app ouverte longtemps et plus tu déploies souvent, plus tu collectionnes les crashs. Et toi, qui recharges la page cinquante fois par jour, tu es structurellement la dernière personne à pouvoir l'observer.

La démonstration : le retry est sans espoir

Le premier réflexe, c'est de réessayer l'import. Intuition raisonnable, mesurablement fausse. L'expérience, dans Chromium, face à un serveur local qui répond 404 sur le chunk puis le rend disponible :

// Essai 1 : le chunk n'existe pas sur le serveur (404)
try {
  await import('/chunk-ABC123.js');
} catch (e) {
  console.log(e); // TypeError: Failed to fetch dynamically imported module: http://.../chunk-ABC123.js
}

// Le serveur sert maintenant le fichier. Essai 2, même URL :
try {
  await import('/chunk-ABC123.js');
} catch (e) {
  console.log(e); // TypeError: Failed to fetch dynamically imported module: http://.../chunk-ABC123.js
}

// Onglet Network : UNE seule requête au total. Le second import()
// a échoué sans même toucher le réseau.

location.reload();
// Après le rechargement, le même import() réussit.

Résultat mesuré sous Chromium : deux échecs identiques, mais une seule requête réseau. Chromium a mémorisé l'échec dans la module map du document (le registre des modules que la page a déjà demandés) : un import() de la même URL rejoue l'échec depuis ce cache tant que la page vit. La spec HTML actuelle dit pourtant l'inverse (l'entrée en échec est retirée de la module map ; un moteur qui la suit à la lettre refait la requête) : le comportement dépend donc du navigateur.

Mais ne te raccroche pas à cette nuance : là où le navigateur refait la requête, elle retombe sur le même 404 : le fichier n'existe plus. Un "retry avec backoff" autour d'un chunk périmé est donc du code mort partout.

Note au passage la formulation de l'erreur : c'est elle que ton filtre va reconnaître. Chromium dit Failed to fetch dynamically imported module, Firefox error loading dynamically imported module, Safari Importing a module script failed. Seule la version Chromium a été revérifiée ici ; les deux autres sont les formulations connues de ces moteurs.

Conclusion : la seule sortie, c'est un rechargement complet, qui rapatrie le nouvel index.html et donc la nouvelle version de l'app.

Le remède immédiat : withNavigationErrorHandler

Quand un loadComponent() rejette, le router transforme l'échec en erreur de navigation (le cycle complet des events de navigation et ses cas d'erreur). Le point d'accroche, c'est withNavigationErrorHandler, une feature de provideRouter :

import { ApplicationConfig, inject } from '@angular/core';
import { Location } from '@angular/common';
import { NavigationError, provideRouter, withNavigationErrorHandler } from '@angular/router';
import { routes } from './app.routes';

const STALE_CHUNK_PATTERNS = [
  'Failed to fetch dynamically imported module', // Chromium
  'error loading dynamically imported module', // Firefox
  'Importing a module script failed', // Safari
];

function isStaleChunkError(error: unknown): boolean {
  return (
    error instanceof TypeError &&
    STALE_CHUNK_PATTERNS.some((pattern) => error.message.includes(pattern))
  );
}

const RELOAD_GUARD_KEY = 'stale-chunk-reload';
const RELOAD_GUARD_WINDOW_MS = 30_000;

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      routes,
      withNavigationErrorHandler((event: NavigationError) => {
        if (!isStaleChunkError(event.error)) {
          return;
        }
        const lastReload = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) ?? 0);
        if (Date.now() - lastReload < RELOAD_GUARD_WINDOW_MS) {
          return; // un rechargement vient d'avoir lieu et n'a rien réparé : ne pas boucler
        }
        sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now()));
        // event.url est l'URL interne du router, sans le base href
        location.assign(inject(Location).prepareExternalUrl(event.url));
      }),
    ),
  ],
};

Quatre détails font la différence entre ce code et le snippet en dix lignes qui traîne partout.

Recharger sur l'URL cible, pas sur la page courante. NavigationError transporte l'URL de la navigation qui a échoué : en rechargeant dessus, l'utilisateur atterrit sur la page qu'il voulait, dans la nouvelle version. Un location.reload() le laisserait sur la page de départ, la navigation en échec n'ayant jamais changé la barre d'adresse. Attention : event.url est l'URL interne du router, sans le base href. Un location.assign(event.url) nu casse dès que <base href> n'est pas / (mesuré : page 404 du navigateur avec /app/). Location.prepareExternalUrl() le réapplique, et comme le handler tourne en contexte d'injection, inject(Location) y est autorisé. Réserve : valable en stratégie path (la valeur par défaut) ; avec withHashLocation(), assigner un fragment #/... ferait une navigation d'ancre sans rechargement.

Le garde-fou anti-boucle. Un chunk peut aussi échouer pour une autre raison : extension qui bloque, réseau capricieux, coupure. Là, recharger ne répare rien, et sans garde-fou tu offres à ton utilisateur une boucle de rechargements infinis. Le sessionStorage borne le remède à un essai par fenêtre de 30 secondes : si l'erreur revient dans la fenêtre, le rechargement n'a rien réparé et le handler s'abstient. Garde aussi en tête que ce handler ne rend pas l'erreur invisible : rechargement ou pas, le router la relance après l'avoir soumise au handler, et elle finit dans ton ErrorHandler, donc dans ton tracker. Le Sentry du lundi verra toujours ces TypeError ; la différence, c'est que l'utilisateur n'est plus coincé. Regroupe-les côté tracker (withRouterConfig({ resolveNavigationPromiseOnError: true }), Angular 17.1+, peut les taire, mais il tait du même coup toutes les erreurs de navigation).

Le filtre sur TypeError et le message. Le handler reçoit toutes les erreurs de navigation, celles de tes guards et resolvers comprises. Recharger la page sur une erreur de resolver HTTP serait une régression violente : on ne traite que la signature précise du chunk périmé.

Le handler ne couvre que les navigations. Un bloc @defer dont le chunk a disparu n'émet pas de NavigationError : il bascule sur son bloc @error (mesuré : le handler ne voit rien passer). Si tes @defer chargent des blocs critiques, c'est dans @error qu'il faut proposer le rechargement.

Deux précisions de version : withNavigationErrorHandler existe depuis Angular 15.2, bien sous le plancher v17. Et depuis Angular 18, le handler peut retourner un RedirectCommand pour convertir l'erreur en redirection interne ; inutile ici : une redirection du router resterait dans la version périmée, précisément ce que tu fuis.

Le remède structurel : le service worker Angular

Le handler ci-dessus soigne le symptôme. Si ton app est du genre à rester ouverte des jours (back-office, dashboard, outil métier), tu peux traiter la cause : garantir qu'une session en cours a toujours accès aux fichiers de sa propre version.

C'est le contrat du service worker Angular (ng add @angular/pwa). Il télécharge et met en cache chaque version de l'app comme un tout cohérent : une session démarrée sur la version de jeudi continue de recevoir les chunks de jeudi depuis le cache, même après le déploiement de vendredi. Dans le cas nominal, le 404 ne se produit plus, la requête n'atteignant plus le serveur. Le cas dégradé existe et l'API le nomme : si le navigateur a partiellement purgé le cache et que le serveur n'a plus les fichiers de cette version, le service worker émet un événement unrecoverable. Le remède y est le même : recharger.

Reste à faire migrer les sessions vers la nouvelle version. SwUpdate expose les deux moments qui comptent :

import { Injectable, inject } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UpdateNotifier {
  private readonly updates = inject(SwUpdate);

  constructor() {
    this.updates.versionUpdates
      .pipe(filter((event): event is VersionReadyEvent => event.type === 'VERSION_READY'))
      .subscribe(() => {
        // À toi de voir : un toast "nouvelle version disponible" qui déclenche
        // document.location.reload(), ou un rechargement silencieux à la
        // prochaine navigation du router.
      });

    this.updates.unrecoverable.subscribe(() => {
      // Le cache n'a plus la version de cette session : recharger est la seule sortie.
      document.location.reload();
    });
  }
}

Piège classique : un service providedIn: 'root' que personne n'injecte n'est jamais instancié. Un inject(UpdateNotifier) dans ton composant racine suffit.

Pèse le coût avant de dégainer : un service worker, c'est une couche de cache de plus à comprendre, un ngsw-config.json à maintenir, et des heures de debug le jour où "la prod sert un vieux fichier". Si ton seul problème est le chunk périmé, withNavigationErrorHandler suffit et tient en une quarantaine de lignes. Le service worker se justifie quand tu veux aussi l'offline, les notifications push, ou ce contrat de cohérence pour des sessions très longues.

Réduire la fenêtre : précharger les chunks

Troisième levier : moins il reste de chunks à télécharger plus tard, moins tu as d'occasions de tomber sur un 404. Le router sait précharger les routes lazy juste après le chargement initial :

import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';

provideRouter(routes, withPreloading(PreloadAllModules));

Les chunks de routes sont alors récupérés dans les secondes qui suivent le bootstrap, pendant que les fichiers de la version courante existent encore. La fenêtre d'exposition se réduit à un déploiement qui tombe pile pendant ces premières secondes. Oui, c'est exactement le réglage que l'article sur le lazy loading qualifie d'anti-pattern : côté réseau, il annule le bénéfice du découpage (seul le démarrage reste allégé). Ici tu l'assumes en connaissance de cause, en échange de robustesse au déploiement ; et pour ne précharger qu'une partie des routes, la stratégie custom du même article fait le compromis. Dernière limite : ça ne couvre que les chunks de routes, pas tes blocs @defer ni tes import() manuels.

Récap actionnable

  • Le trio outputHashing: "all" + onglets longue durée + déploiement atomique t'expose à des Failed to fetch dynamically imported module à chaque mise en prod qui modifie un chunk lazy. Ce n'est pas un bug à corriger, c'est une propriété de ton pipeline à gérer.
  • N'écris pas de retry autour d'un import() de chunk périmé : le fichier n'existe plus côté serveur, et Chromium ne refait même pas la requête. Mesuré plus haut.
  • Pose withNavigationErrorHandler dans provideRouter : filtre la signature exacte de l'erreur, recharge sur l'URL cible via Location.prepareExternalUrl(), garde-fou sessionStorage contre les boucles. C'est le remède minimal.
  • Les blocs @defer ne passent pas par le router : leur chunk périmé atterrit dans @error, c'est là que tu le gères.
  • Sessions très longues ou besoin d'offline : le service worker Angular sert à chaque session les fichiers de sa propre version, SwUpdate.versionUpdates te donne le hook de mise à jour, et unrecoverable te signale le cas où le cache a lâché.
  • withPreloading(PreloadAllModules) réduit la fenêtre d'exposition en téléchargeant les chunks de routes pendant qu'ils existent encore. Anti-pattern perf assumé, complémentaire, pas suffisant seul.
  • Un pic de TypeError sur des chunks juste après un déploiement n'est pas du bruit : c'est le signal exact de ce piège.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.