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 vialoadComponent/loadChildren),@deferblocks (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 :
| Levier | Effet | Piège |
|---|---|---|
prefetch on hover + render on interaction | chunk 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 vite | allonge artificiellement l'affichage du placeholder |
@loading (after 100ms; minimum 200ms) | n'affiche le spinner que si le chargement dure >100ms, et au moins 200ms | sur 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 :
| Trigger | Déclenchement | Note 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/#ref | parfait pour modales, accordéons, éditeurs |
on hover(ref?) | mouseenter / focusin | ne 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 vraie | one-shot : une fois true → rendu → ne se re-defer plus |
prefetch on <trigger> | télécharge le chunk, ne rend pas | orthogonal au trigger de render ; c'est le levier « zéro latence perçue » |
hydrate on <trigger> | SSR uniquement : retarde l'hydratation du JS | rend 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).
{
"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.
// 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.
// 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.
<!-- 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.
// 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.
{
"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.
{
"name": "@my-org/shared",
"version": "1.0.0",
"sideEffects": false,
"exports": {
".": "./dist/index.js"
}
}Preload strategy custom basée sur la navigation prédite.
// 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-in →
provideClientHydration(withIncrementalHydration()).- Angular 20+ : elle est activée par défaut dès
provideClientHydration(). On l'éteint explicitement avecwithNoIncrementalHydration(). Le helperwithEventReplay()(rejeu des events sur du DOM pas encore hydraté) reste recommandé. ÉcrireprovideClientHydration({ withIncrementalHydration: true })(objet littéral) ne compile pas — ce sont des features en arguments variadiques, pas un objet d'options.
// 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()),
],
};// 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;<!-- template avec hydration différée par bloc @defer -->
@defer (hydrate on viewport) {
<app-comments />
}Analyse du bundle avec source-map-explorer.
# build avec source maps
ng build --source-map=true
# analyse
npx source-map-explorer dist/app/browser/main.*.jsEsbuild metafile pour analyse fine.
# 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.
<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.
# 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 -cAvec budgets dans angular.json, le build échoue automatiquement.
"budgets": [
{ "type": "initial", "maximumWarning": "300kb", "maximumError": "500kb" },
{ "type": "bundle", "name": "vendor", "maximumWarning": "1mb" }
]Analyse régulière en CI.
# .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// 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.
npm install --save-dev @lhci/cli
npx lhci autorun --collect.url=http://localhost:4200// .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.
// 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),
},
];// 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); }
}// 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>();
}// 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?.(); }
}// 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.
// 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.
// 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�.AbortControllercâblé auDestroyRef: si l'utilisateur ferme le panneau (le@deferest détruit côté parent), le fetch est annulé → on arrête de payer des tokens. Le bouton Stop appelle le mêmeabort().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.
// 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(jamaisnew Anthropic()dans un champ), un endpoint qui streame en SSE, écoutereq.on('close')pour propager l'AbortControllerau 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
| Pattern | Utiliser quand | Éviter quand |
|---|---|---|
| Lazy routes | Chaque feature top-level | Mini-app < 5 routes |
@defer | Composants below-the-fold ou conditionnels | Composants critiques pour le LCP |
prefetch | UI qui suggère une navigation imminente | Tout systématiquement (annule l'intérêt) |
| Dynamic imports | Libs lourdes utilisées rarement | Code partagé (préférer chunks normaux) |
| SSR + hydration | Apps publiques (SEO, FCP critique) | Apps internes / dashboards privés |
| Hydration incrémentale | Pages avec beaucoup de composants | Pages simples (overhead inutile) |
| Budgets | Toujours | Jamais — c'est gratuit et préventif |
source-map-explorer | Audit régulier (mensuel ou par PR) | Audit unique ponctuel |
size-limit en CI | Apps avec exigences perf strictes | Prototypes / MVP |
PreloadAllModules | Petite app avec navigation rapide attendue | Grande 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 collexlsx(~90 KB) au chunk qui importe le service ; comme le service estprovidedIn: 'root', il atterrit dansmain.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'xlsxa 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 :hoverne se déclenche pas au touch ; ajoute un fallbackprefetch on idlepour ces clients. Le placeholder doit réservermin-height/widthexacts, 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 barrelindex.tsqui re-exporte tout).source-map-explorermontrera des fichiers du shell dans le chunk admin. Fix : imports directs (pas de barrel), etprovidedIn: 'root'pour les services partagés (ils restent dansmain.jsau 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 unAbortControllerpartagé fetch + signalstatus. Production-grade : propage l'abort au serveur (req.on('close')) pour ne pas payer de tokens après l'arrêt, et gardemarkedhors du bundle initial viaimport('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" }ousize-limitavec"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 (sanshydrate) : 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
- Angular esbuild builder : https://angular.dev/tools/cli/esbuild
@deferblocks : https://angular.dev/guide/templates/defer- Lazy loading : https://angular.dev/guide/routing/lazy-loading
- source-map-explorer : https://github.com/danvk/source-map-explorer
- size-limit : https://github.com/ai/size-limit
- Lighthouse CI : https://github.com/GoogleChrome/lighthouse-ci
- Web Vitals : https://web.dev/vitals
- Article Minko Gechev — performance tips Angular
- Bundle Buddy / esbuild-visualizer pour analyses visuelles