📧 Reste informé(e) !

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

S'inscrire gratuitement

~10 min de lecture

"Le lazy loading Angular ça sert à rien" : 5 niveaux de découpage que tu ignores

Tu l'as forcément entendu en réunion technique ou lu sur un thread : "le lazy loading en Angular, c'est de la branlette d'archi, ça change rien". Et ceux qui disent ça ont un demi-argument valable : mal fait, le lazy loading ne sert effectivement à rien. Un loadComponent sur une route jamais visitée derrière un PreloadAllModules, c'est du théâtre. Tu déclares du lazy, et le navigateur télécharge tout quand même trois secondes après le boot.

Mais conclure "ça sert à rien" de "je l'ai mal configuré", c'est comme dire que les tests sont inutiles parce que les tiens sont flaky. Le lazy loading Angular, ce n'est pas une feature, c'est cinq mécanismes distincts à cinq niveaux de granularité. La plupart des devs en connaissent un seul. On les déroule, avec pour chacun le quand et le comment.


Le vrai problème : ton bundle initial fait 900 KB et personne ne le regarde

Prends une app Angular moyenne : dashboard, back-office admin, tunnel de paiement, éditeur riche quelque part. En eager total, tout part dans main.js. L'utilisateur qui atterrit sur le login télécharge, parse et évalue le code de l'admin qu'il ne verra jamais.

Le coût n'est pas que le transfert réseau (que le gzip et le HTTP/2 atténuent, d'où le "ça sert à rien"). Le vrai coût, c'est le parse + compile + évaluation JS sur le thread principal : 900 KB de JS, c'est ~1,5 s de CPU bloqué sur un mobile moyen avant même que ton premier effect() tourne. Ton TBT et ton TTI explosent, Lighthouse te met 40.

Le lazy loading ne réduit pas la quantité totale de code. Il réduit ce qui est sur le chemin critique du premier rendu. Voilà l'objectif mesurable. On y va niveau par niveau.


Niveau 1 : le routing, la base que tout le monde connaît (mal)

La forme canonique existe depuis les composants standalone (stables en Angular 15) : loadComponent, sans NgModule à déclarer autour ni loadChildren: () => import('./x.module').then((m) => m.XModule) verbeux comme avant. Angular 17 a juste rendu standalone implicite. Et comme les pages sont des export default, l'import se réduit à sa plus simple expression.

// app.routes.ts
import { Routes } from '@angular/router';
import HomePage from './home/home.page'; // export default -> import direct

export const routes: Routes = [
  {
    path: '',
    component: HomePage, // premier écran : eager, jamais lazy
  },
  {
    path: 'admin',
    loadComponent: () => import('./admin/admin.page'),
  },
  {
    path: 'checkout',
    loadChildren: () => import('./checkout/checkout.routes').then((m) => m.CHECKOUT_ROUTES),
  },
];

Note bien la home (path: '') : elle reste eager, importée en dur et passée en component. C'est le premier écran ; la lazy-loader n'ajouterait qu'un aller-retour réseau sur le chemin critique, exactement ce que le lazy loading est censé éviter (on y revient plus bas). Tout le reste part en lazy : loadComponent pour une page isolée, loadChildren pour un sous-arbre de routes (le tunnel de checkout et ses cinq étapes partagent un chunk). Chaque import() devient un chunk séparé à la compilation ; le chunk admin n'est téléchargé que quand quelqu'un navigue vers /admin.

C'est le niveau que tout le monde connaît. Et c'est aussi celui qui donne l'illusion que "ça sert à rien", parce que 90% des devs le sabotent au niveau suivant.


Niveau 2 : le preloading, là où le lazy loading meurt ou brille

Voilà l'erreur qui alimente le mythe. Tu as bien découpé tes routes, puis tu colles ça dans ta config :

// ❌ L'anti-pattern qui annule ton découpage
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';

provideRouter(routes, withPreloading(PreloadAllModules));

PreloadAllModules télécharge tous tes chunks lazy dès que l'app est stable, en background. Résultat : ton bundle initial est propre, mais 400 ms après le boot, le navigateur récupère l'admin, le checkout et les paramètres. Tu as payé la complexité du découpage pour zéro bénéfice réseau. C'est exactement ce que critiquent les sceptiques, et ils ont raison sur ce cas précis.

La bonne réponse, ce n'est pas "pas de preloading" (naviguer vers une route non préchargée ajoute une latence réseau visible). C'est un preloading sélectif. Angular te laisse écrire ta propre stratégie :

// selective-preload.strategy.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class SelectivePreload implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    return route.data?.['preload'] ? load() : of(null);
  }
}
// app.routes.ts
{
  path: 'checkout',
  data: { preload: true }, // parcours à forte intention → on précharge
  loadChildren: () => import('./checkout/checkout.routes').then((m) => m.CHECKOUT_ROUTES),
},
{
  path: 'admin',
  // pas de flag → jamais préchargé, réservé aux 2% d'admins
  loadComponent: () => import('./admin/admin.page'),
},
provideRouter(routes, withPreloading(SelectivePreload));

Le @Injectable({ providedIn: 'root' }) n'est pas optionnel : withPreloading résout la stratégie via un useExisting, un alias qui ne provisionne pas la classe. Sans lui, c'est NullInjectorError au premier resolve du router.

Tu précharges le checkout, tu laisses l'admin en pur lazy, et tu peux même conditionner sur navigator.connection.saveData pour ne rien précharger en 3G. C'est ici que se joue tout le débat : le lazy loading sans stratégie de preloading réfléchie, ça ne sert à rien. Avec, c'est un levier direct sur ton TTI.


Niveau 3 : les composants avec @defer, le lazy loading intra-page

Le routing découpe entre les pages. À l'intérieur d'une page lourde, tout part encore dans le même chunk. Une page produit avec carrousel, 150 avis, FAQ et widget de chat : l'utilisateur qui lit le prix attend le parse du SDK de chat utilisé par 3% des visiteurs.

Depuis Angular 17, @defer règle ça sans toucher au routing :

@defer (on viewport; prefetch on idle) {
  <app-reviews [productId]="productId()" />
} @placeholder {
  <div class="h-96 animate-pulse rounded-lg bg-gray-100"></div>
}

@defer (on idle) {
  <app-chat-widget />
}

Chaque bloc @defer crée son propre chunk, chargé selon un trigger (on viewport, on idle, on interaction, on timer, when condition()). Reviews sort du bundle de la page et n'arrive qu'au scroll.

Je ne redéroule pas tous les triggers ici, c'est un sujet à part entière : le détail (placeholder, loading, error, prefetch, pièges SSR) est dans l'article dédié @defer : le lazy loading de composants que tu ignores. Retiens juste que c'est le niveau que le routing ne peut pas adresser, et le plus sous-exploité du framework.


Niveau 4 : les services et providers, le lazy loading qu'on oublie toujours

Point contre-intuitif : un service providedIn: 'root' n'est pas dans ton bundle initial juste parce qu'il est root. Le tree-shaking l'exclut tant que personne ne l'inject(). Le vrai piège, c'est quand un service root traîne une grosse dépendance et qu'il est injecté par un composant eager.

// ❌ Ce service tire une lib de 200 KB dans le bundle initial
@Injectable({ providedIn: 'root' })
export class PdfExport {
  private readonly pdfMaker = inject(HeavyPdfLibrary); // 200 KB, chargé au boot
}

Un mot sur les décorateurs avant de continuer : je garde @Injectable({ providedIn: 'root' }) dans les exemples ci-dessous, valable depuis Angular 15 donc copiable quelle que soit ta version. Sur Angular 22, tu peux tout écrire avec son raccourci @Service() (je le montre au point c) : c'est cosmétique pour le lazy loading, le comportement d'injection est identique.

Si PdfExport est injecté dans le header (eager), sa lib PDF part dans main.js, même si l'export n'est cliqué qu'une fois sur cent. Trois façons de le rendre vraiment lazy :

a) Providers scopés à une route lazy. Le provider n'est instancié que quand la route est active. Le détail qui fait tout : pour que son code soit lui aussi lazy, déclare-le dans la config de routes chargée en loadChildren, pas dans un app.routes.ts eager. Sinon l'import statique de ReportGenerator en tête du fichier eager le remet dans le bundle initial (instance différée, mais code chargé d'entrée).

// reports.routes.ts, chargé via loadChildren -> ReportGenerator vit dans le chunk lazy
import { Routes } from '@angular/router';
import { ReportGenerator } from './report-generator';

export const REPORTS_ROUTES: Routes = [
  {
    path: '',
    providers: [ReportGenerator], // instance ET code liés au chunk lazy
    loadComponent: () => import('./reports.page'),
  },
];

b) import() dynamique dans la méthode. La lib lourde n'est chargée qu'au moment de l'action :

@Injectable({ providedIn: 'root' })
export class PdfExport {
  async export(data: ReportData): Promise<void> {
    const { generatePdf } = await import('./heavy-pdf-library'); // chunk à la demande
    generatePdf(data);
  }
}

Le chunk heavy-pdf-library n'existe pour le navigateur que quand export() est appelé. C'est du lazy loading de logique métier, invisible dans la plupart des audits de bundle, et souvent le plus gros gain caché.

c) injectAsync, la primitive native (Angular 22). Le await import() manuel marche, mais tu recâbles à la main la résolution DI et le cache à chaque appel. Depuis Angular 22, injectAsync fait le boulot : tu déclares un service lazy au niveau du champ, Angular capture le contexte d'injection, résout via la DI et mémoïse le résultat. Bonus, un prefetch déclaratif.

import { Service, injectAsync, onIdle } from '@angular/core';

@Service() // Angular 22 : raccourci de @Injectable({ providedIn: 'root' })
export class ReportExporter {
  // le chunk pdf-export sort du bundle, préchargé quand le navigateur est idle
  private readonly pdf = injectAsync(
    () => import('./pdf-export').then((m) => m.PdfExport),
    { prefetch: onIdle },
  );

  async export(data: ReportData): Promise<void> {
    (await this.pdf()).generate(data);
  }
}

Au passage, @Service() est le nouveau raccourci Angular 22 de @Injectable({ providedIn: 'root' }) : même comportement, nom qui dit l'intention, et il impose l'inject() (plus d'injection par constructeur). Le service lazy ciblé, PdfExport, doit lui aussi être auto-provisionné, en @Service() ou @Injectable({ providedIn: 'root' }), sinon injectAsync n'a rien à résoudre. Tu gardes le code splitting de l'import() dynamique, avec en plus une résolution DI propre et un prefetch déclaratif. Le point b) rendu idiomatique.


Niveau 5 : le SSR, où le lazy loading a une sémantique différente

En SSR/SSG, le raisonnement s'inverse partiellement. Le lazy loading de route reste pertinent pour le bundle client hydraté, mais le serveur, lui, exécute le code de la page pour produire le HTML.

Le vrai sujet SSR, c'est l'hydratation incrémentale, introduite en developer preview en Angular 19 et stabilisée en Angular 20. Un @defer classique n'est pas rendu côté serveur (seul le placeholder l'est), ce qui casse le SEO si le contenu compte. hydrate change ça :

// app.config.ts
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';

provideClientHydration(withIncrementalHydration());
@defer (hydrate on viewport) {
  <app-reviews [productId]="productId()" />
}

Avec hydrate on viewport, le contenu est rendu dans le HTML SSR (bon pour le crawl et le LCP), mais le JS d'hydratation de ce bloc n'est exécuté qu'au scroll. Contenu visible immédiatement, interactivité chargée paresseusement : le lazy loading appliqué non plus au HTML mais à l'hydratation, un niveau qui n'existait pas avant.


Quand le lazy loading ne sert VRAIMENT à rien (soyons honnêtes)

Les sceptiques ne se trompent pas toujours. Cas où tu perds ton temps, voire tu dégrades :

  • App minuscule. Trois routes, bundle sous 150 KB. Le découpage ajoute des allers-retours réseau pour un gain nul. Reste eager.
  • Contenu above the fold. Ne mets jamais en lazy ce qui est visible au premier écran. Tu ajoutes un chunk sur le chemin critique et tu dégrades ton LCP. Le hero n'est jamais un @defer.
  • Sur-découpage. Cinquante micro-chunks de 4 KB, c'est cinquante requêtes. Le coût de latence dépasse le bénéfice. Le HTTP/2 aide mais ne l'annule pas.
  • Preload aveugle. On l'a vu : PreloadAllModules sur toute l'app annule le bénéfice réseau. Autant rester eager et assumer.

Le lazy loading est un scalpel, pas un interrupteur global. Appliqué partout sans mesure, il peut effectivement "ne servir à rien". C'est ce constat mal digéré qui a produit le mythe.


Le bonus rarement cité : c'est aussi de l'éco-conception

Réduire le JS envoyé, parsé et exécuté a un second effet, moins glamour qu'un score Lighthouse : moins d'énergie consommée, côté réseau (chaque octet transféré coûte de bout en bout) comme côté terminal (le parse/eval réveille le CPU et vide la batterie sur mobile). C'est ce que demande le RGESN, le référentiel d'écoconception de l'État : ne charger que le nécessaire, quand c'est nécessaire. Reste honnête : le gain est réel mais modeste face au poids d'une image mal compressée ou d'une vidéo en autoplay. Un critère de sobriété numérique, pas un absolu qui rachète le reste.


Récap actionnable

Niveau Mécanisme Quand
Routing loadComponent / loadChildren Toute route hors du premier écran
Preloading PreloadingStrategy custom + data.preload Précharger seulement les parcours à forte intention
Composant @defer + triggers Sections lourdes intra-page (avis, chat, éditeur)
Service Providers scopés route, import() dynamique, injectAsync (v22) Logique métier lourde et rarement déclenchée
SSR @defer (hydrate ...) + withIncrementalHydration Contenu SEO-critique mais interactivité différable

Le process :

  1. Ouvre ton bundle analyzer (esbuild-visualizer sur le stats JSON) et repère ce qui est sur le chemin critique.
  2. Passe en lazy toute route hors du premier écran, puis remplace le preload par une stratégie sélective.
  3. Traque les composants lourds under the fold, colle-leur un @defer (on viewport).
  4. Chasse les libs lourdes tirées par des services eager, isole-les en import() dynamique (ou injectAsync si tu es en Angular 22).
  5. Si tu fais du SSR, bascule tes @defer SEO-critiques en hydrate.
  6. Mesure au Lighthouse avant/après. Regarde le TBT et le TTI, pas juste la taille transférée.

Le lazy loading Angular ne se résume pas à loadComponent. C'est cinq leviers sur cinq axes, et si la plupart n'en connaissent qu'un, la vraie phrase n'est pas "ça sert à rien" mais un seul niveau mal configuré ne sert à rien. Les cinq, bien orchestrés, c'est la différence entre un 40 et un 95 en perf.


📚 Envie de creuser Angular ?

👉🏼 ➡️ Découvre EasyAngularKit

📧 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.