Skip to content

Bundle size — esbuild, @defer, lazy routes, code splitting

TL;DR Depuis Angular 17, le builder par défaut est esbuild (@angular-devkit/build-angular:application), qui remplace Webpack et produit des bundles 20-40% plus petits et 3-5x plus rapides à builder. En 2026, optimiser la taille du bundle repose sur quatre leviers : lazy routes (chargement à la demande via loadComponent/loadChildren), @defer blocks (différer des fragments de template selon des triggers), tree-shaking strict (sideEffects: false, imports nominaux), et mesure réelle (Lighthouse, esbuild metafile, source-map-explorer). Le but n'est pas un bundle minuscule mais un Time To Interactive rapide — ce qui implique souvent de livrer moins dès le premier chargement plutôt que de tout compresser.

🧠 Mental model — ASCII + analogie

Le bundle Angular suit une logique de chunks : un point d'entrée (main.js) charge le runtime, l'app shell et les routes initiales. Chaque lazy route devient un chunk séparé chargé à la navigation. Chaque @defer block devient un chunk chargé selon son trigger. Le tree-shaking élague à la compilation tout ce qui n'est pas importé. Le code splitting sépare en plusieurs chunks pour paralléliser les téléchargements et bénéficier du cache.

        ┌─────────────────────────────────────────────────────────────┐
        │                Architecture du bundle Angular                │
        └─────────────────────────────────────────────────────────────┘

  ┌──────────────────┐
  │ index.html       │  ← 2 KB
  └──────┬───────────┘
         │ loads

  ┌──────────────────┐  ┌──────────────────┐
  │ polyfills.js     │  │ main.js          │  ← ~80-150 KB gzip
  │ ~10 KB (zoneless)│  │ runtime + shell  │     (cible 2026)
  └──────────────────┘  │ + route initiale │
                        └──┬───────────────┘

              ┌────────────┼────────────────────────────┐
              ▼            ▼                            ▼
       ┌────────────┐ ┌────────────┐              ┌────────────┐
       │ chunk-A.js │ │ chunk-B.js │   ...         │ chunk-N.js │
       │ /dashboard │ │ /users     │              │ /admin     │
       │ lazy route │ │ lazy route │              │ lazy route │
       └────────────┘ └────────────┘              └────────────┘


       ┌────────────┐
       │ chunk-X.js │  ← @defer block (chart lourd)
       └────────────┘

  Objectif :
  - main.js (initial) : minimal, juste app shell + route home
  - lazy chunks : à la demande
  - @defer chunks : à la condition (viewport, idle, interaction)

L'analogie : un bundle classique, c'est déménager toute sa maison avant de pouvoir entrer. Code splitting + lazy routes, c'est ouvrir la porte avec juste les clés, et chercher les autres affaires au fur et à mesure qu'on en a besoin. Le visiteur (l'utilisateur) peut entrer immédiatement. Bonus : avec @defer, on précharge intelligemment ce que l'utilisateur va probablement vouloir, sans bloquer l'entrée.

La machine à états d'un @defer block

Un @defer n'est pas un simple « if asynchrone ». C'est une machine à états compilée par Angular, avec deux axes orthogonaux qu'un dev confond souvent : le trigger de prefetch (quand télécharger le chunk JS) et le trigger de render (quand instancier et afficher le composant). Les séparer est tout l'art : on télécharge tôt (au hover), on rend tard (au clic) → zéro latence perçue.

   prefetch trigger              render (main) trigger
   (télécharge le chunk)         (instancie le composant)
        │                              │
        ▼                              ▼
  ┌───────────┐  fetch ok   ┌───────────────┐  trigger   ┌──────────┐
  │ @placeholder│──────────▶│ chunk en cache │──────────▶│ @loading │
  │  (état 0)   │           │  (pas rendu)   │           │ (≥min?)  │
  └───────────┘             └───────────────┘            └────┬─────┘
        ▲                          │ fetch KO                 │ ok
        │ (jamais re-render        ▼                          ▼
        │  automatique)      ┌──────────┐               ┌──────────┐
        └────────────────────│  @error  │               │ rendu réel│
                             └──────────┘               └──────────┘

  Règles compilées :
  - @placeholder est rendu TOUT DE SUITE (compte dans le LCP initial !)
  - @loading n'apparaît qu'APRÈS le trigger render, le temps que le chunk
    s'instancie. minimum/after lissent le flash (anti-flicker).
  - une fois en état "rendu réel", il n'y a PAS de retour arrière :
    un @defer ne se "re-defer" pas. Pour re-tester une condition,
    il faut détruire/recréer le bloc (ex: via @if englobant).

Trois conséquences que les seniors exploitent :

LevierEffetPiège
prefetch on hover + render on interactionchunk déjà en cache au clic → render instantanéhover sans clic = bande passante gaspillée sur mobile (pas de hover réel)
@placeholder (minimum 500ms)empêche un flash si le composant arrive trop viteallonge artificiellement l'affichage du placeholder
@loading (after 100ms; minimum 200ms)n'affiche le spinner que si le chargement dure >100ms, et au moins 200mssur fibre, @loading ne s'affiche jamais — c'est voulu

Le modèle mental clé : @placeholder est du contenu eager (il pèse dans ton bundle initial et ton LCP), @loading/@error sont deferred avec le bloc. Un @placeholder trop lourd annule le bénéfice du @defer.

Grammaire complète des triggers et paramètres de timing

Trop de devs ne connaissent que on viewport. Voici la grammaire que le compilateur Angular accepte, à avoir en tête en revue de code :

TriggerDéclenchementNote senior
on idle (défaut si rien)requestIdleCallback (fallback timeout)le plus sûr par défaut pour du below-the-fold non urgent
on viewport(ref?)IntersectionObserver sur le @placeholder (ou un #ref)exige un @placeholder non vide pour avoir un élément à observer
on interaction(ref?)click ou keydown sur le placeholder/#refparfait pour modales, accordéons, éditeurs
on hover(ref?)mouseenter / focusinne se déclenche pas au touch → toujours doubler d'un fallback (on idle) sur mobile
on timer(<durée>)délai fixe (2s, 500ms)rarement le bon outil ; sent le hack
when <expr signal/booléen>expression réactive vraieone-shot : une fois true → rendu → ne se re-defer plus
prefetch on <trigger>télécharge le chunk, ne rend pasorthogonal au trigger de render ; c'est le levier « zéro latence perçue »
hydrate on <trigger>SSR uniquement : retarde l'hydratation du JSrend le HTML côté serveur (≠ on viewport qui ne rend rien en SSR)

Paramètres sur les sous-blocs, qui lissent les transitions (anti-flicker) :

  • @loading (after 100ms; minimum 200ms) — n'affiche le spinner que si le chargement dure plus de 100 ms (after), et si affiché, le garde au moins 200 ms (minimum). Sur fibre, le spinner ne clignote jamais.
  • @placeholder (minimum 500ms) — garde le placeholder au moins 500 ms même si le chunk arrive en 20 ms. Évite le flash placeholder→contenu.

@defer et change detection : ce qui change en zoneless

Un piège que peu anticipent : les triggers @defer ne déclenchent pas Zone.js. IntersectionObserver, requestIdleCallback, les listeners interaction/hover posés par le compilateur sont patchés pour notifier le scheduler Angular directement. Donc @defer fonctionne identiquement en zoneless (provideZonelessChangeDetection()) — c'est même un de ses arguments : on supprime ~30 KB de Zone.js et le rendu deferred reste réactif. En revanche, à l'intérieur d'un bloc rendu, c'est ton composant lazy qui doit être OnPush/signal-based : un composant lourd Default qui re-rend à chaque tick annule une partie du gain perf que @defer t'a donné sur le bundle. Charger tard un composant mal optimisé, c'est juste déplacer le problème.

🛠️ Code minimal (ts + html)

Configuration esbuild dans angular.json (par défaut depuis Angular 17).

json
{
  "projects": {
    "app": {
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:application",
          "options": {
            "outputPath": "dist/app",
            "index": "src/index.html",
            "browser": "src/main.ts",
            "polyfills": [],
            "tsConfig": "tsconfig.app.json",
            "assets": ["src/favicon.ico", "src/assets"],
            "styles": ["src/styles.scss"],
            "scripts": []
          },
          "configurations": {
            "production": {
              "budgets": [
                { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" },
                { "type": "anyComponentStyle", "maximumWarning": "4kb" }
              ],
              "outputHashing": "all",
              "optimization": true,
              "sourceMap": false,
              "namedChunks": false,
              "extractLicenses": true,
              "fileReplacements": [
                { "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" }
              ]
            }
          }
        }
      }
    }
  }
}

Lazy routes avec loadComponent et loadChildren.

ts
// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./home/home.page').then((m) => m.HomePage),
  },
  {
    path: 'dashboard',
    loadChildren: () => import('./dashboard/dashboard.routes').then((m) => m.dashboardRoutes),
  },
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then((m) => m.adminRoutes),
    canMatch: [
      () => {
        // garde qui empêche le chargement du chunk si l'utilisateur n'est pas admin
        const auth = inject(AuthService);
        return auth.isAdmin();
      },
    ],
  },
];

@defer block avec différents triggers.

ts
// home.page.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HeavyChartComponent } from './heavy-chart.component';
import { CommentsComponent } from './comments.component';
import { RelatedProductsComponent } from './related-products.component';

@Component({
  selector: 'app-home',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [HeavyChartComponent, CommentsComponent, RelatedProductsComponent],
  template: `
    <header>
      <h1>Bienvenue</h1>
    </header>

    <section class="hero">
      <p>Contenu critique chargé immédiatement.</p>
    </section>

    <!-- Trigger viewport : charge le chunk quand le bloc entre dans le viewport -->
    @defer (on viewport) {
      <app-heavy-chart />
    } @placeholder {
      <div class="skeleton">Chargement du graphique…</div>
    } @loading (minimum 200ms) {
      <p>Chargement…</p>
    } @error {
      <p>Erreur de chargement.</p>
    }

    <!-- Trigger interaction : charge à l'hover/click -->
    @defer (on interaction) {
      <app-comments />
    } @placeholder {
      <button>Voir les commentaires</button>
    }

    <!-- Trigger idle : charge quand le navigateur est idle -->
    @defer (on idle) {
      <app-related-products />
    } @placeholder {
      <div class="skeleton-list"></div>
    }
  `,
})
export class HomePage {}

Triggers @defer combinés et conditions.

html
<!-- Charge dès qu'une de ces conditions est vraie -->
@defer (on viewport; on hover(button); when shouldLoad()) {
  <app-heavy-component />
}

<!-- Preload (charge le chunk) sans render (rendu différé) -->
@defer (on idle; prefetch on hover(button)) {
  <app-modal />
} @placeholder {
  <button>Ouvrir modal</button>
}

<!-- Trigger timer pour charger après un délai -->
@defer (on timer(2s)) {
  <app-newsletter-signup />
}

Dynamic import manuel pour charger un module externe à la demande.

ts
// excel-export.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ExcelExportService {
  async export(data: unknown[]): Promise<void> {
    // xlsx n'est pas dans le bundle initial
    const { utils, writeFile } = await import('xlsx');
    const sheet = utils.json_to_sheet(data);
    const book = utils.book_new();
    utils.book_append_sheet(book, sheet, 'Export');
    writeFile(book, 'export.xlsx');
  }
}

tsconfig.json strict pour optimiser le tree-shaking.

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "strict": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "importHelpers": true,
    "useDefineForClassFields": false
  },
  "angularCompilerOptions": {
    "strictTemplates": true,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true
  }
}

package.json avec sideEffects: false pour les librairies internes.

json
{
  "name": "@my-org/shared",
  "version": "1.0.0",
  "sideEffects": false,
  "exports": {
    ".": "./dist/index.js"
  }
}

Preload strategy custom basée sur la navigation prédite.

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

@Injectable({ providedIn: 'root' })
export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    // Ne précharge pas en 3G/2G ou data saver
    const conn = (navigator as any).connection;
    if (conn?.saveData || conn?.effectiveType?.includes('2g')) {
      return of(null);
    }
    // Délai pour ne pas concurrencer le chargement initial
    const delay = (route.data?.['preloadDelay'] as number) ?? 2000;
    return timer(delay).pipe(mergeMap(() => load()));
  }
}

// app.config.ts
import { provideRouter, withPreloading } from '@angular/router';
provideRouter(routes, withPreloading(NetworkAwarePreloadStrategy));

Configuration SSR avec hydration incrémentale.

Piège de version (à connaître en 2026) : l'API a bougé entre Angular 19 et 20.

  • Angular 19 : l'hydration incrémentale est opt-inprovideClientHydration(withIncrementalHydration()).
  • Angular 20+ : elle est activée par défaut dès provideClientHydration(). On l'éteint explicitement avec withNoIncrementalHydration(). Le helper withEventReplay() (rejeu des events sur du DOM pas encore hydraté) reste recommandé. Écrire provideClientHydration({ withIncrementalHydration: true }) (objet littéral) ne compile pas — ce sont des features en arguments variadiques, pas un objet d'options.
ts
// app.config.ts (partagé client + serveur)
import { ApplicationConfig } from '@angular/core';
import {
  provideClientHydration,
  withEventReplay,
  // withNoIncrementalHydration, // ← seulement si tu veux DÉSACTIVER (A20+)
} from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    // A20+ : hydration incrémentale ON par défaut ; on garde l'event replay.
    provideClientHydration(withEventReplay()),
  ],
};
ts
// main.server.ts (A20 : provideServerRendering vient de @angular/ssr)
import { bootstrapApplication } from '@angular/platform-browser';
import { provideServerRendering } from '@angular/ssr';
import { mergeApplicationConfig } from '@angular/core';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

const serverConfig = mergeApplicationConfig(appConfig, {
  providers: [provideServerRendering()],
});

const bootstrap = () => bootstrapApplication(AppComponent, serverConfig);

export default bootstrap;
html
<!-- template avec hydration différée par bloc @defer -->
@defer (hydrate on viewport) {
  <app-comments />
}

Analyse du bundle avec source-map-explorer.

bash
# build avec source maps
ng build --source-map=true

# analyse
npx source-map-explorer dist/app/browser/main.*.js

Esbuild metafile pour analyse fine.

bash
# Angular esbuild builder écrit un metafile en mode verbose
ng build --verbose --stats-json
# génère stats.json à analyser avec esbuild visualizer ou bundle-buddy
npx esbuild-visualizer --metadata dist/app/stats.json

🎯 Patterns courants

Lazy routes pour chaque feature. Chaque feature top-level (dashboard, admin, settings, profile) doit être lazy-loadée. Au minimum, on découpe par section principale. Le main.js ne doit contenir que le shell et la page d'atterrissage.

@defer pour les composants secondaires. Tout composant below the fold ou non immédiatement nécessaire doit être en @defer. Triggers courants : on viewport pour le contenu scroll, on interaction pour les modals/dropdowns, on idle pour les widgets secondaires.

prefetch pour anticiper. Si on sait que l'utilisateur va probablement charger un composant (sur hover d'un bouton), on précharge le chunk : @defer (on interaction; prefetch on hover(button)). Le chunk est téléchargé en arrière-plan, mais le composant n'est rendu qu'au clic.

Dynamic imports pour les libs lourdes. Pour des libs utilisées seulement dans un cas (PDF generation, Excel export, charts complexes), on les importe dynamiquement à la demande : const { jsPDF } = await import('jspdf'). Ces libs ne sont pas dans le bundle initial.

Pas de barrel files (index.ts) pour les libs internes. Un barrel qui re-exporte tout d'un dossier (export * from './a'; export * from './b') casse le tree-shaking si les bundlers ne peuvent pas déterminer ce qui est utilisé. Préférer des imports directs : import { Foo } from '@my-org/shared/foo'.

Imports nominaux, pas wildcards. import * as moment from 'moment' empêche le tree-shaking et embarque toute la lib. import { format } from 'date-fns' n'embarque que format et ses dépendances. Préférer les libs modulaires (date-fns > moment, lodash-es > lodash).

Budgets dans angular.json. Définir des budgets par type (initial, anyComponentStyle, bundle) qui font échouer le build au-dessus d'un seuil. Cela évite les régressions silencieuses. Cibles 2026 : initial < 500 KB warning, < 1 MB error.

Standalone components et imports précis. Avec standalone, chaque composant déclare ses imports explicitement. Pas de NgModule géant qui importe tout. Le bundler peut tree-shaker précisément.

SSR + hydration partielle. Pour réduire le LCP, Angular Universal (SSR) avec hydration partielle (Angular 16+) sert un HTML pré-rendu. Le bundle JS hydrate ensuite. Combiné avec @defer, certaines parties ne sont jamais hydratées si l'utilisateur ne les voit pas.

Mesurer en conditions réelles. Lighthouse en CI, WebPageTest sur connexions lentes (3G), Real User Monitoring (RUM) avec Web Vitals. Les conditions de dev (localhost, fiber, MacBook M3) ne reflètent pas la réalité.

Compression Brotli + gzip côté serveur. Le bundle Angular doit être servi en Brotli (plus efficace) avec fallback gzip. Sur des serveurs modernes (nginx, Caddy, Cloudflare), cela divise par 4-5 la taille transférée. Toujours vérifier que les headers Content-Encoding sont corrects en production.

HTTP/2 ou HTTP/3 pour les chunks parallèles. Le code splitting est efficace seulement si les chunks peuvent se charger en parallèle. HTTP/2 multiplexe, HTTP/3 (QUIC) est encore plus rapide sur connexions lossy. Vérifier dans le Network panel que les chunks sont bien parallélisés.

Optimisation des images en parallèle au JS. Un bundle JS optimisé ne sauve pas un LCP si l'image hero pèse 2 MB. Utiliser AVIF/WebP, le loading="lazy" natif, <picture> avec sources adaptatives, et le composant NgOptimizedImage d'Angular (@angular/common) qui automatise les bonnes pratiques.

html
<img ngSrc="hero.webp" width="1200" height="600" priority alt="..." />

Trois cibles bundle 2026 (App Angular « typique »). Initial JS < 200 KB gzip, CSS < 30 KB gzip, LCP < 2.5s sur 4G simulée, TBT < 200 ms. Au-delà, viser Lighthouse 95+. Une app SPA mature qui dépasse 500 KB initial est un signe qu'il faut auditer en profondeur.

🔄 Versions — Angular 16 → 20

Angular 16 (mi-2023) : Webpack par défaut. esbuild builder en preview (@angular-devkit/build-angular:browser-esbuild). Hydration partielle introduite (SSR). @defer annoncé pour la suite.

Angular 17 (fin 2023) : esbuild devient le défaut via le nouveau builder application. Builds 3-5x plus rapides. Nouveau control flow (@if, @for, @switch, @defer) en preview. @defer blocks fonctionnels avec triggers.

Angular 18 (mi-2024) : esbuild builder stabilisé. @defer stable. Préchargement intelligent des routes (PreloadAllModules amélioré). Hydration améliorée (event replay).

Angular 19 (fin 2024) : zoneless en preview impacte le bundle (Zone.js supprimé = ~30 KB économisés). @defer gagne des triggers supplémentaires (on timer, conditions multiples). Source map workflow amélioré.

Angular 20 (mi-2025) : optimisation tree-shaking étendue (Angular Compiler tree-shake plus agressivement). Hydration incrémentale stable et activée par défaut avec provideClientHydration() (opt-out via withNoIncrementalHydration()) ; provideServerRendering() migre vers @angular/ssr. Le bundle Angular « hello world » descend autour de 35 KB gzip.

Trajectoire 2026 : la stack moderne est esbuild + standalone + signals + zoneless + SSR avec hydration incrémentale. Les apps modernes atteignent des Lighthouse scores 90+ même sur connexions lentes, avec des bundles initiaux sous 200 KB.

⚠️ Pitfalls — 6-10

1. Imports de bibliothèques entières. import * as _ from 'lodash' embarque 90 KB. import { debounce } from 'lodash-es' embarque ~2 KB. Toujours vérifier la version ES module modulaire et les imports nominaux.

2. Charger toutes les locales d'i18n. import '@angular/common/locales/fr' plus 30 autres locales chargées même si on n'en utilise qu'une. Importer dynamiquement selon la langue de l'utilisateur, ou lazy-load les locales secondaires.

3. loadChildren qui charge tout dès le démarrage. PreloadAllModules charge toutes les routes lazy après le boot. Sur une grosse app, c'est contre-productif. Préférer une stratégie custom qui précharge selon la navigation probable (Quicklink, hover-based).

4. @defer avec un placeholder trop léger. Un placeholder vide ou trop petit cause un layout shift quand le composant arrive. Toujours réserver l'espace : skeleton avec dimensions proches du composant final.

5. Lazy route qui ré-importe le shell. Si une feature module importe AppModule ou des composants du shell, le tree-shaking ne peut pas séparer. Symptôme : chaque chunk lazy pèse 200+ KB au lieu de 20 KB. Auditer les imports avec source-map-explorer.

6. sideEffects: true dans une lib interne. Par défaut, npm considère les packages comme ayant des side effects. Pour les libs internes purement fonctionnelles, ajouter "sideEffects": false dans package.json permet au bundler de tree-shaker agressivement.

7. Polyfills inutiles. Angular CLI configure des polyfills pour navigateurs anciens. En 2026, si on cible ES2022+, on peut souvent retirer Zone.js (zoneless) et autres polyfills. Auditer src/polyfills.ts ou la config polyfills dans angular.json.

8. Source maps en production. Activées sans précaution, elles exposent le code source et alourdissent le déploiement. Soit on les désactive (sourceMap: false), soit on les sépare et on les pousse vers Sentry/Datadog pour le debug sans les servir publiquement.

9. Bundles non hashés. Sans outputHashing: 'all', les fichiers gardent le même nom à chaque build. Le cache navigateur sert l'ancien fichier après un deploy. Toujours activer le hashing en production.

10. Optimiser sans mesurer. Passer 3 jours à shaver 5 KB sur une lib alors que le LCP est dominé par une image hero non optimisée. Toujours mesurer Lighthouse / Web Vitals avant et après chaque optimisation. Cibler les gains réels, pas les statistiques flatteuses.

🧪 Testing

Le testing de la taille du bundle est principalement automatisé en CI.

bash
# script CI
ng build --configuration=production
ls -lh dist/app/browser/*.js

# vérifier que main.js < 200 KB gzip
gzip -c dist/app/browser/main.*.js | wc -c

Avec budgets dans angular.json, le build échoue automatiquement.

json
"budgets": [
  { "type": "initial", "maximumWarning": "300kb", "maximumError": "500kb" },
  { "type": "bundle", "name": "vendor", "maximumWarning": "1mb" }
]

Analyse régulière en CI.

yaml
# .github/workflows/bundle-size.yml
name: Bundle size
on: [pull_request]
jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build
      - run: npx size-limit
json
// package.json
{
  "size-limit": [
    { "path": "dist/app/browser/main.*.js", "limit": "200 KB" },
    { "path": "dist/app/browser/polyfills.*.js", "limit": "20 KB" }
  ]
}

Tests Lighthouse en CI avec lighthouse-ci.

bash
npm install --save-dev @lhci/cli
npx lhci autorun --collect.url=http://localhost:4200
json
// .lighthouserc.json
{
  "ci": {
    "collect": { "numberOfRuns": 3 },
    "assert": {
      "preset": "lighthouse:recommended",
      "assertions": {
        "first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    }
  }
}

🎬 Cas d'usage concrets

Scénario 1 — SaaS RH, @defer sur modules lourds

Contexte : le SaaS RH embarque un éditeur d'offres d'emploi WYSIWYG (~250 KB de TinyMCE), un éditeur de templates email Markdown (~180 KB), et un module de signature électronique (~320 KB avec dépendances PDF.js). Ces trois modules sont utilisés par moins de 20% des recruteurs et jamais sur la même session. Approche : chaque éditeur encapsulé dans un composant standalone, importé via @defer (on interaction) ou @defer (on viewport) selon le cas. L'éditeur WYSIWYG sur la page création offre se charge @defer (on interaction; on idle) : la zone affiche d'abord un @placeholder léger (textarea simple), et au premier focus l'éditeur lourd est téléchargé et hydraté. L'éditeur de signature dans le détail candidature est en @defer (on viewport) car le bouton signer est en bas de page. Résultat mesuré : main.js réduit de 750 KB à 280 KB, TTI mobile 4G passé de 6.8s à 2.4s, et zéro impact UX puisque les chargements deferred sont quasi-invisibles (transition @loading avec skeleton).

Scénario 2 — E-commerce mode, lazy routes

Contexte : le retailer mode a une app monolithique avec 30 routes (catalogue, fiche produit, panier, checkout, compte, programme fidélité, wishlist, blog, magasins, contact). Le premier bundle pesait 1.9 MB. Approche : chaque route racine en loadComponent ou loadChildren, séparation stricte entre zone publique (catalogue, fiche, blog — chargée immédiatement), zone transactionnelle (panier, checkout — lazy mais préchargée via PreloadAllModules après idle), et zone compte client (fidélité, wishlist, historique — lazy stricte, jamais préchargée). Les routes lazy partagent un chunk commun (composants UI réutilisés) extrait automatiquement par esbuild grâce au tree-shaking. Le résultat : bundle initial à 280 KB (HTML + main.js + styles critiques inlinés), chunks par route entre 30 et 80 KB, Lighthouse Performance à 96 sur mobile 4G simulée, et navigation entre routes en ~150ms grâce au preload.

Scénario 3 — Cabinet juridique, éditeur de document lourd

Contexte : l'application du cabinet inclut un éditeur de conclusions juridiques basé sur ProseMirror (~400 KB avec plugins), un viewer PDF (~600 KB avec PDF.js + worker), et un module de comparaison de versions (~150 KB avec diff-match-patch). Ces modules sont utilisés uniquement quand l'avocat ouvre un document. Approche : la route /dossiers/:id/documents/:docId est lazy en loadComponent. À l'intérieur, le viewer PDF est chargé via @defer (when isPdf()) qui ne se déclenche que si le document est un PDF. L'éditeur ProseMirror est chargé via @defer (on interaction) au clic sur "Modifier", avec un @placeholder qui affiche le texte en read-only via un simple <pre>. Le module de comparaison est @defer (on hover) sur le bouton "Comparer" pour pré-charger le code dès l'intention. Mesure : bundle initial app cabinet à 350 KB, chunks lazy par feature, l'avocat qui ne consulte que des PDFs ne télécharge jamais l'éditeur ProseMirror.

🛠️ Exemple end-to-end

Use case : page création d'offre d'emploi du SaaS RH avec éditeur WYSIWYG en @defer.

ts
// offre-create.routes.ts
import { Routes } from '@angular/router';

export const OFFRE_ROUTES: Routes = [
  {
    path: 'offres/create',
    loadComponent: () => import('./offre-create.component').then((m) => m.OffreCreateComponent),
  },
];
ts
// offre-create.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RichEditorPlaceholderComponent } from './rich-editor-placeholder.component';

@Component({
  selector: 'app-offre-create',
  imports: [FormsModule, RichEditorPlaceholderComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h1>Nouvelle offre d'emploi</h1>

    <label>Titre du poste
      <input [(ngModel)]="titre" name="titre" required />
    </label>

    <label>Description
      <!-- @if keyé : recréer le bloc force un nouveau cycle @defer (retry après @error) -->
      @if (editorKey() >= 0) {
        @defer (on interaction(editorTrigger); on idle; prefetch on hover(editorTrigger)) {
          <app-rich-editor [(content)]="description" />
        } @placeholder (minimum 500ms) {
          <app-rich-editor-placeholder #editorTrigger [(content)]="description" />
        } @loading (minimum 200ms) {
          <div class="skeleton">Chargement de l'éditeur…</div>
        } @error {
          <p>Impossible de charger l'éditeur. <button (click)="retry()">Réessayer</button></p>
        }
      }
    </label>

    <label>Aperçu PDF
      @defer (on viewport) {
        <app-pdf-preview [content]="description()" />
      } @placeholder { <div class="pdf-skel">Faites défiler pour voir l'aperçu</div> }
    </label>

    <button (click)="save()" [disabled]="!titre()">Enregistrer</button>
  `,
})
export class OffreCreateComponent {
  protected readonly titre = signal('');
  protected readonly description = signal('');
  // un @defer ne se "re-defer" pas : pour rejouer le chargement après @error,
  // on détruit puis recrée le bloc en togglant un @if englobant.
  protected readonly editorKey = signal(0);

  save() { /* envoi API */ }
  retry() { this.editorKey.update((k) => k + 1); }
}
ts
// rich-editor-placeholder.component.ts
import { ChangeDetectionStrategy, Component, model } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-rich-editor-placeholder',
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <textarea
      class="placeholder-editor"
      [ngModel]="content()"
      (ngModelChange)="content.set($event)"
      rows="8"
      placeholder="Rédigez la description du poste…"
    ></textarea>
    <small>Cliquez ici pour activer l'éditeur enrichi (mise en forme, listes, images).</small>
  `,
  styles: [`.placeholder-editor { width: 100%; font-family: system-ui; }`],
})
export class RichEditorPlaceholderComponent {
  readonly content = model.required<string>();
}
ts
// rich-editor.component.ts (LE composant lourd, isolé)
import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy, afterNextRender, inject, model } from '@angular/core';

@Component({
  selector: 'app-rich-editor',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div #host class="rich-host"></div>`,
  styles: [`.rich-host { min-height: 240px; border: 1px solid #ccc; }`],
})
export class RichEditorComponent implements OnDestroy {
  readonly content = model.required<string>();
  private host = inject(ElementRef<HTMLElement>);
  private editor: any = null;

  constructor() {
    afterNextRender(async () => {
      const { default: TinyMCE } = await import('tinymce/tinymce');
      await import('tinymce/themes/silver');
      await import('tinymce/plugins/lists');
      this.editor = await TinyMCE.init({
        target: this.host.nativeElement,
        plugins: 'lists link image',
        toolbar: 'undo redo | bold italic | bullist numlist',
        setup: (ed: any) => ed.on('change', () => this.content.set(ed.getContent())),
        initial_value: this.content(),
      });
    });
  }

  ngOnDestroy() { this.editor?.destroy?.(); }
}
jsonc
// angular.json — budgets
{
  "configurations": {
    "production": {
      "budgets": [
        { "type": "initial", "maximumWarning": "300kb", "maximumError": "400kb" },
        { "type": "anyComponentStyle", "maximumWarning": "6kb" }
      ]
    }
  }
}

Bundle initial maintenu sous 300 KB grâce à @defer + lazy routes, éditeur lourd téléchargé uniquement à l'interaction, prefetch au hover pour anticiper sans bloquer.

🤖 Charger une UI d'agent IA sans plomber le bundle initial

Une UI d'agent (chat LLM, copilote, panneau d'outils) est l'archétype du code lourd, secondaire et conditionnel : markdown renderer + sanitizer + highlighter de code + lib de diff peuvent peser 150-300 KB, alors que 70% des utilisateurs n'ouvrent jamais le panneau. C'est exactement la cible de @defer + dynamic imports. Mais attention : un panneau d'agent streame des tokens, et le découpage en chunks interagit avec le rendu temps réel. Voici comment un senior câble ça.

1. Le panneau d'agent en @defer, le renderer markdown en dynamic import

Le panneau lui-même est @defer (on interaction) (ouvert au clic sur un bouton « Assistant »). À l'intérieur, le renderer markdown — souvent le plus gros morceau — est chargé dynamiquement, pas importé en statique.

ts
// markdown.service.ts — le renderer n'est JAMAIS dans le bundle initial
import { Injectable } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class MarkdownService {
  private sanitizer = inject(DomSanitizer);
  private parserPromise?: Promise<(md: string) => string>;

  // import() résolu une seule fois, puis mis en cache
  private parser(): Promise<(md: string) => string> {
    this.parserPromise ??= import('marked').then((m) => {
      const marked = new m.Marked({ gfm: true, breaks: true });
      return (md: string) => marked.parse(md) as string;
    });
    return this.parserPromise;
  }

  async render(markdown: string): Promise<SafeHtml> {
    const parse = await this.parser();
    const raw = parse(markdown);
    // JAMAIS bypassSecurityTrustHtml sur de la sortie LLM brute :
    // sanitize() retire <script>, on:* handlers, javascript: URIs.
    return this.sanitizer.sanitize(1 /* SecurityContext.HTML */, raw) ?? '';
  }
}

Pourquoi marked en dynamic import plutôt qu'en imports: [...] ? Parce qu'un import statique le colle au chunk du composant — donc au chunk du panneau. En dynamic import, esbuild le sort dans son propre chunk, partagé entre toutes les bulles, téléchargé une seule fois au premier message, et mémoïsé par la parserPromise.

2. Streaming SSE + buffer append-only coalescé en rAF (zoneless)

Le piège classique : un signal.set() par token. À 100 tokens/s, ça force 100 cycles de CD/s, chacun re-parsant le markdown → saccade visible. La solution senior : accumuler dans un buffer mutable, flusher le signal une fois par frame via requestAnimationFrame.

ts
// agent-stream.ts
import { Injectable, signal, inject, DestroyRef } from '@angular/core';

export interface AgentMessage {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  status: 'streaming' | 'done' | 'error';
}

@Injectable({ providedIn: 'root' })
export class AgentStreamService {
  private destroyRef = inject(DestroyRef);
  readonly messages = signal<AgentMessage[]>([]);

  private pendingBuffer = '';
  private rafId: number | null = null;
  private controller: AbortController | null = null;

  async send(prompt: string, generationId: string): Promise<void> {
    this.controller?.abort(); // une seule génération à la fois
    this.controller = new AbortController();
    this.destroyRef.onDestroy(() => this.controller?.abort());

    this.messages.update((m) => [
      ...m,
      { id: generationId, role: 'assistant', content: '', status: 'streaming' },
    ]);

    try {
      const res = await fetch('/api/agent/stream', {
        method: 'POST',
        headers: { 'content-type': 'application/json', 'idempotency-key': generationId },
        body: JSON.stringify({ prompt }),
        signal: this.controller.signal,
      });
      if (!res.body) throw new Error('no stream');

      const reader = res.body.getReader();
      const decoder = new TextDecoder();

      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        // parse SSE "data: ..." lines -> ici simplifié en texte brut
        this.pendingBuffer += decoder.decode(value, { stream: true });
        this.scheduleFlush(generationId);
      }
      this.flush(generationId);
      this.patch(generationId, (msg) => ({ ...msg, status: 'done' }));
    } catch (e) {
      if ((e as Error).name !== 'AbortError') {
        this.patch(generationId, (msg) => ({ ...msg, status: 'error' }));
      }
    }
  }

  // coalescing : au plus 1 flush par frame, peu importe le débit de tokens
  private scheduleFlush(id: string): void {
    if (this.rafId !== null) return;
    this.rafId = requestAnimationFrame(() => {
      this.rafId = null;
      this.flush(id);
    });
  }

  private flush(id: string): void {
    if (!this.pendingBuffer) return;
    const chunk = this.pendingBuffer;
    this.pendingBuffer = '';
    this.patch(id, (msg) => ({ ...msg, content: msg.content + chunk }));
  }

  private patch(id: string, fn: (m: AgentMessage) => AgentMessage): void {
    this.messages.update((list) => list.map((m) => (m.id === id ? fn(m) : m)));
  }

  stop(): void {
    this.controller?.abort(); // annule fetch ; le serveur doit écouter req.on('close')
  }
}

Points senior qui séparent le code production du tutoriel :

  • Coalescing rAF : le débit de tokens (réseau) est découplé du débit de render (écran). On ne re-rend jamais plus de 60×/s, même si le LLM crache 200 tokens/s.
  • getReader() + TextDecoder({ stream: true }) : décodage incrémental UTF-8 correct — un emoji multi-byte coupé entre deux chunks réseau ne produit pas de .
  • AbortController câblé au DestroyRef : si l'utilisateur ferme le panneau (le @defer est détruit côté parent), le fetch est annulé → on arrête de payer des tokens. Le bouton Stop appelle le même abort().
  • idempotency-key: generationId : si le client réessaie, le serveur (NestJS/BullMQ) déduplique la génération au lieu de relancer un appel LLM facturé.

3. Markdown mémoïsé + timeline d'outils en union discriminée

Le rendu markdown coûte cher : on le mémoïse pour ne re-parser que le contenu qui a changé. Et la trace d'outils de l'agent (tool calls) est une union discriminée rendue en @switch — chaque état (pending | running | streaming | done | error) a son visuel.

ts
// agent-bubble.component.ts
import { ChangeDetectionStrategy, Component, computed, inject, input, resource } from '@angular/core';
import { MarkdownService } from './markdown.service';
import { AgentMessage } from './agent-stream';

type ToolStep =
  | { kind: 'pending'; name: string }
  | { kind: 'running'; name: string; startedAt: number }
  | { kind: 'streaming'; name: string; partial: string }
  | { kind: 'done'; name: string; result: unknown }
  | { kind: 'error'; name: string; message: string };

@Component({
  selector: 'app-agent-bubble',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article [class.streaming]="message().status === 'streaming'">
      @if (html.value(); as safe) {
        <div [innerHTML]="safe"></div>
      } @else {
        <pre>{{ message().content }}</pre>
      }

      @for (step of steps(); track step.name) {
        @switch (step.kind) {
          @case ('pending')   { <span class="step muted">⏳ {{ step.name }}</span> }
          @case ('running')   { <span class="step">⚙️ {{ step.name }}…</span> }
          @case ('streaming') { <span class="step">📡 {{ step.name }} : {{ step.partial }}</span> }
          @case ('done')      { <span class="step ok">✅ {{ step.name }}</span> }
          @case ('error')     { <span class="step err">❌ {{ step.name }}</span> }
        }
      }
    </article>
  `,
})
export class AgentBubbleComponent {
  private md = inject(MarkdownService);
  readonly message = input.required<AgentMessage>();
  readonly steps = input<ToolStep[]>([]);

  // resource() (Angular 19/20) re-rend le markdown quand le content change,
  // sans re-parser à chaque tick : `params` est la computation réactive,
  // le loader ne re-tourne QUE si `params` change de valeur.
  // NB: en A20, l'option s'appelle bien `params` (ex-`request` de la v19) ;
  // le loader reçoit aussi `abortSignal` pour annuler un parse en vol.
  readonly html = resource({
    params: () => this.message().content,
    loader: ({ params }) => this.md.render(params),
  });
}

Côté serveur (NestJS), la contrepartie est un client LLM injecté via forRootAsync (jamais new Anthropic() dans un champ), un endpoint qui streame en SSE, écoute req.on('close') pour propager l'AbortController au SDK, et un cost-guard / rate-limit à l'edge. Modèles à jour : claude-opus-4-8 (flagship), claude-sonnet-4-6, claude-haiku-4-5. Le SDK gère les retries ; on streame avec l'API Async. Détails dans les fichiers NestJS dédiés au streaming et aux jobs BullMQ.

🔁 Quand utiliser / éviter

PatternUtiliser quandÉviter quand
Lazy routesChaque feature top-levelMini-app < 5 routes
@deferComposants below-the-fold ou conditionnelsComposants critiques pour le LCP
prefetchUI qui suggère une navigation imminenteTout systématiquement (annule l'intérêt)
Dynamic importsLibs lourdes utilisées rarementCode partagé (préférer chunks normaux)
SSR + hydrationApps publiques (SEO, FCP critique)Apps internes / dashboards privés
Hydration incrémentalePages avec beaucoup de composantsPages simples (overhead inutile)
BudgetsToujoursJamais — c'est gratuit et préventif
source-map-explorerAudit régulier (mensuel ou par PR)Audit unique ponctuel
size-limit en CIApps avec exigences perf strictesPrototypes / MVP
PreloadAllModulesPetite app avec navigation rapide attendueGrande app avec beaucoup de routes

🏋️ Exercices

Progression : implémenter → mesurer/rendre production-grade → casser puis réparer. Règle d'or de tout l'exercice : aucune optimisation n'est validée sans un chiffre avant/après (source-map-explorer, budget, ou Lighthouse). Optimiser à l'aveugle est le pitfall n°10.

Exercice 1 — Sortir une lib lourde du bundle initial (et le prouver)

Objectif : faire passer un export Excel de « dans le main.js » à « chargé à la demande », chiffres à l'appui.

Pars d'un service qui fait import * as XLSX from 'xlsx' en haut de fichier, utilisé par un bouton « Exporter ». Build, et mesure la taille de main.js avec source-map-explorer. Refactore en const XLSX = await import('xlsx') dans la méthode. Re-build, re-mesure. Documente le delta en KB gzip et vérifie dans le Network panel que xlsx n'est téléchargé qu'au clic.

Indice/Solution : le import * statique colle xlsx (~90 KB) au chunk qui importe le service ; comme le service est providedIn: 'root', il atterrit dans main.js. Le dynamic import crée un chunk séparé chargé au clic. Attention : si un autre fichier importe encore le service en statique de façon eager, le chunk fusionne — vérifie au metafile (ng build --stats-json) qu'xlsx a bien son propre chunk.

Exercice 2 — @defer avec prefetch découplé du render

Objectif : zéro latence perçue à l'ouverture d'une modale lourde.

Crée une modale (~120 KB, ex. un date-range picker avec calendrier) ouverte par un bouton. Étape 1 : @defer (on interaction) — observe au Network (throttle 3G) le délai de chargement au clic. Étape 2 : ajoute prefetch on hover(btn) pour télécharger le chunk au survol. Mesure le temps clic→render dans les deux cas. Étape 3 : ajoute un @placeholder avec des dimensions identiques à la modale et vérifie un CLS de 0.

Indice/Solution : @defer (on interaction(btn); prefetch on hover(btn)). Sur 3G, le hover donne ~300-800ms d'avance → render quasi-instantané au clic. Le piège mobile : hover ne se déclenche pas au touch ; ajoute un fallback prefetch on idle pour ces clients. Le placeholder doit réserver min-height/width exacts, sinon le calendrier qui arrive pousse le contenu (CLS > 0).

Exercice 3 — Traquer un chunk lazy qui ré-importe le shell

Objectif : diagnostiquer pourquoi un chunk lazy pèse 220 KB au lieu de 25 KB.

On te donne (ou tu fabriques) une feature lazy /admin dont le chunk fait 220 KB. Lance source-map-explorer dist/app/browser/chunk-admin.*.js. Identifie le module dupliqué. Corrige et re-mesure jusqu'à < 40 KB.

Indice/Solution : le coupable habituel est un import { AppComponent } ou un service eager du shell tiré dans la feature (souvent via un barrel index.ts qui re-exporte tout). source-map-explorer montrera des fichiers du shell dans le chunk admin. Fix : imports directs (pas de barrel), et providedIn: 'root' pour les services partagés (ils restent dans main.js au lieu d'être dupliqués par chunk). Ajoute un budget { "type": "bundle", "name": "admin", "maximumError": "60kb" } pour bloquer la régression.

Exercice 4 — Streaming d'agent IA sans saccade, avec Stop

Objectif : afficher un flux de tokens à 100 chunks/s sous 60fps, annulable côté client ET serveur.

Simule un endpoint SSE qui émet un token toutes les ~8ms. Branche-le naïvement (signal.set() par token, markdown re-parsé à chaque fois) et profile : tu verras la saccade. Refactore avec le buffer append-only coalescé en rAF + le renderer markdown en dynamic import mémoïsé de la section IA. Ajoute un bouton Stop câblé sur AbortController. Mesure les ticks/s et la taille du main.js (le renderer ne doit PAS y être).

Indice/Solution : naïf ≈ 120 ticks/s, chacun re-parse → jank. Coalescé = ≤ 60 flushes/s, markdown mémoïsé via resource()/computed. Le Stop est un AbortController partagé fetch + signal status. Production-grade : propage l'abort au serveur (req.on('close')) pour ne pas payer de tokens après l'arrêt, et garde marked hors du bundle initial via import('marked') (vérifie au metafile).

Exercice 5 (architecte) — Budget de bundle qui bloque la PR

Objectif : empêcher une régression de taille de passer en review.

Configure size-limit (ou les budgets angular.json) avec une limite serrée sur main.js et un job CI sur pull_request. Introduis volontairement une régression (un import * as _ from 'lodash') et vérifie que la CI passe au rouge et bloque le merge. Bonus : fais commenter le diff de taille sur la PR.

Indice/Solution : budget { "type": "initial", "maximumError": "300kb" } ou size-limit avec "limit": "200 KB". La régression lodash ajoute ~70 KB → dépasse le seuil → exit code ≠ 0 → CI rouge. Pour le commentaire de PR, andresz1/size-limit-action. Leçon : un budget mesure la taille transférée gzip, pas le disque — c'est ce que paie l'utilisateur.

Exercice 6 (architecte) — Hydration incrémentale sur une page deferred

Objectif : servir un HTML SSR sans hydrater le JS du contenu jamais vu.

Mets en place SSR (provideServerRendering) + provideClientHydration(withIncrementalHydration()). Place une section lourde sous @defer (hydrate on viewport). Charge la page, ouvre le Network, et prouve que le chunk de la section n'est ni téléchargé ni hydraté tant qu'on ne scrolle pas jusqu'à elle — alors que son HTML est bien dans la réponse SSR initiale (donc visible et indexable).

Indice/Solution : @defer (hydrate on viewport) rend le HTML côté serveur (présent dans la source → SEO/LCP OK) mais retarde le téléchargement + l'hydratation du JS jusqu'au trigger. Compare avec un @defer (on viewport) classique (sans hydrate) : celui-ci ne rend rien côté serveur (placeholder seulement). Le piège : un event utilisateur (clic) sur une zone non encore hydratée doit être rejoué — vérifie que l'event replay d'Angular le capture, sinon le premier clic est perdu.

🎤 En entretien

Q : « Quelle est la différence entre une lazy route et un @defer block, et quand utiliser l'un plutôt que l'autre ? » R : Une lazy route découpe au niveau navigation : le chunk est chargé quand l'utilisateur arrive sur l'URL, c'est la granularité « page ». Un @defer découpe à l'intérieur d'une vue : un fragment de template est chargé selon un trigger (viewport, interaction, idle, timer, condition) sans changer d'URL. Règle : lazy route pour les features top-level (dashboard, admin), @defer pour le contenu secondaire d'une page (chart below-the-fold, modale, éditeur lourd). Les deux se combinent : une route lazy peut contenir des @defer.

Q : « prefetch on hover télécharge le chunk au survol — pourquoi ne pas tout précharger pour aller plus vite ? » R : Parce que la bande passante et la batterie ne sont pas gratuites, surtout sur mobile et data-saver. Tout précharger (PreloadAllModules) annule le bénéfice du code splitting : on re-télécharge l'app entière après le boot, en concurrence avec les ressources critiques. Le prefetch ciblé exploite un signal d'intention (hover, viewport approchant) pour ne charger que ce que l'utilisateur va probablement utiliser. Une stratégie custom (network-aware : pas de prefetch en 2G/saveData) est l'approche senior. Et prefetch est orthogonal au render : on télécharge tôt, on rend tard.

Q : « Ton chunk lazy /admin pèse 220 KB au lieu de 25 KB. Comment tu débugges ? » R : source-map-explorer dist/.../chunk-admin.*.js (ou le metafile esbuild via ng build --stats-json). Je cherche les modules du shell dupliqués dans le chunk — symptôme typique d'un import qui tire AppComponent, un service non-providedIn: 'root', ou un barrel index.ts qui re-exporte tout un dossier et casse le tree-shaking. Fix : imports directs, services en providedIn: 'root' (restent dans main.js, pas dupliqués), et un budget par bundle pour bloquer la régression future.

Q : « Tu streames des tokens d'un LLM dans une UI Angular et ça saccade, et le renderer markdown gonfle ton bundle. Deux problèmes, comment tu les règles ? » R : Saccade = un tick de CD par token. Je coalesce : accumuler les tokens dans un buffer et flusher le signal une fois par frame via requestAnimationFrame (≤ 60 ticks/s au lieu de 100+). Je mémoïse le rendu markdown (resource()/computed) pour ne pas re-parser tout à chaque flush, et je sanitize via DomSanitizer (jamais bypassSecurityTrustHtml sur de la sortie LLM). Bundle = le renderer (marked) en dynamic import mémoïsé, pas en import statique, pour qu'esbuild le sorte dans son propre chunk chargé au premier message. Et un bouton Stop sur AbortController câblé au DestroyRef, propagé au serveur (req.on('close')) pour ne pas payer de tokens après annulation.

🔗 Liens

Bibliothèque tech perso — Achref