~11 min de lecture
280 ms de gel sur une frappe : ce calcul n'a rien à faire dans ton main thread
Tape "wireless keyboard" dans la recherche de ton back-office : les lettres tombent par paquets, le caret se fige, le
hover des boutons meurt. Plus la requête s'allonge, plus la frappe coûte cher - jusqu'à plus d'un quart de seconde
sans réponse. Pourtant le composant est propre : un signal pour la saisie, un computed() pour le scoring, OnPush
partout.
Ce n'est pas ta réactivité qui est en cause, c'est l'endroit où le calcul s'exécute : le seul thread qui fait tourner ton JavaScript et met à jour le DOM. Voyons pourquoi aucune API Angular ne peut te sauver, et comment un Web Worker sort le calcul de là - avec les pièges qui t'attendent en chemin.
TL;DR
JavaScript exécute ton code sur un seul thread, celui qui met à jour le DOM : pendant un calcul synchrone, aucun
événement n'est traité, aucune mise à jour du DOM n'atteint l'écran. OnPush et les signals décident quand
recalculer, @defer quand charger : aucun ne réduit la durée du calcul. Quand l'optimisation ne suffit plus, le Web
Worker déplace le calcul sur un thread séparé : messages typés, service qui expose le résultat en signal. En échange :
une copie des données à chaque message - d'où un dataset qui traverse une fois -, rien de partagé par défaut entre
worker et app, et une détection statique du new Worker qui casse en silence dès que l'URL ou le chemin passe par une
variable.
Exemples en syntaxe moderne (signals, control flow), Angular 17+ avec le builder
application; les comportements de build décrits ont été vérifiés en Angular 22. Les mesures viennent de Node 24 (médiane sur 7 exécutions) sur la machine de rédaction : retiens les ordres de grandeur, pas la milliseconde.
La démonstration : un computed() propre qui gèle tout
La version naïve, propre en apparence :
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { rankMatches } from './rank-matches';
import { PRODUCT_NAMES } from './product-names'; // 100 000 names
@Component({
selector: 'app-product-finder',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input type="search" [value]="query()" (input)="onQuery($event)" />
<ul>
@for (match of matches(); track match.name) {
<li>{{ match.name }}</li>
}
</ul>
`,
})
export class ProductFinder {
protected readonly query = signal('');
protected readonly matches = computed(() => rankMatches(this.query(), PRODUCT_NAMES));
protected onQuery(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
}
}
rankMatches calcule une distance d'édition (Levenshtein) entre la requête et chacun des 100 000 noms, trie, et garde
les 20 meilleurs.
// rank-matches.ts - pure function, no Angular import
export interface Match {
name: string;
score: number;
}
export function rankMatches(query: string, names: string[], limit = 20): Match[] {
const q = query.toLowerCase();
const scored = names.map((name) => ({ name, score: levenshtein(q, name.toLowerCase()) }));
scored.sort((a, b) => a.score - b.score);
return scored.slice(0, limit);
}
function levenshtein(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
let prev = new Array<number>(b.length + 1);
let curr = new Array<number>(b.length + 1);
for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i = 1; i <= a.length; i++) {
curr[0] = i;
for (let j = 1; j <= b.length; j++) {
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
[prev, curr] = [curr, prev];
}
return prev[b.length];
}
Mesuré sur ce code, avec 100 000 noms : environ 30 ms à une lettre, 130 ms à dix, 280 ms à vingt. Pendant ces millisecondes, le main thread est occupé à 100 % et rien n'atteint l'écran. À vingt caractères, l'interaction dépasse le seuil "bon" de l'INP (Interaction to Next Paint, le délai entre un geste de l'utilisateur et la prochaine mise à jour de l'écran), fixé à 200 ms. C'est la métrique que l'hydratation incrémentale attaque par l'autre bout.
Aucune API Angular ne peut réduire cette durée. OnPush et les signals décident quand recalculer, pas à quelle
vitesse. Un debounce espace les exécutions, mais chacune gèle toujours l'écran. @defer retarde le chargement d'un
composant ; il n'enlève rien au coût de son calcul : une fois le bloc chargé, chaque frappe paie le scoring plein pot.
Le main thread est une file : tant que ta fonction n'a pas rendu la main, rien d'autre n'y passe.
Trois issues, dont aucune n'est une API Angular : rendre le calcul moins cher (borner la distance : abandonner un candidat en cours de calcul, dès que toute sa ligne courante dépasse le score du vingtième retenu), le découper en tranches qui rendent la main au navigateur - par paquets de 2 000 noms, le blocage tombe sous les 10 ms - ou le déplacer sur un autre thread. Les deux premières se tentent sans sortir du composant, même si le découpage rend déjà le résultat asynchrone comme le ferait un worker : commence par elles. La troisième s'appelle un Web Worker.
La solution : le calcul déménage, la vue ne s'en aperçoit pas
Un Web Worker est un thread séparé, fourni par le navigateur, qui exécute ton JavaScript sans toucher au DOM ; tout passe par messages. Le CLI a un schematic dédié :
ng generate web-worker search
Il crée search.worker.ts (avec /// <reference lib="webworker" /> en tête) et un tsconfig.worker.json, référencé
dans la config du projet (webWorkerTsConfig). Le filet est plus mince qu'il n'y paraît. Sous le builder
application, cette option n'est pas lue au build : vérifié en Angular 22.0.7, le chunk produit (le fichier
JavaScript chargé séparément) est identique avec ou sans elle - il vient de la détection statique du new Worker(...)
(on y revient). Le type-check, lui, dépend de la tsConfig de ta cible build, car ce new Worker n'est pas un
import pour TypeScript : le fichier du worker n'entre dans le programme que si un glob l'y met. Le tsconfig du CLI
(include: ["src/**/*.ts"]) le couvre - le worker y est typé avec les libs de l'app, une faute de frappe sur
data.query casse le build, mais document.title passe. Un tsconfig.app.json qui liste ses entrées
(files: ["src/main.ts"] et un include réduit aux .d.ts) ne le couvre pas : le worker part alors en production
sans le moindre contrôle de types. Ni le CLI ni Nx ne génèrent cette forme, mais elle existe dans les configs
reprises à la main : ouvre la tienne avant de conclure. Dans les deux régimes,
tsc -p tsconfig.worker.json --noEmit, en CI par exemple, est le seul filet qui refuse document (TS2584) - et il
attrape aussi la faute de frappe.
Le protocole de messages, typé
Première décision de design : le worker reçoit le dataset une fois, puis seulement les requêtes (on y revient au piège 2). Une union discriminée décrit les messages entrants (si besoin, va voir comment une union discriminée rend les états invalides inexprimables) :
// search.messages.ts
import type { Match } from './rank-matches';
export type SearchWorkerInput =
| { kind: 'init'; names: string[] }
| { kind: 'search'; id: number; query: string };
export interface SearchWorkerOutput {
id: number;
matches: Match[];
}
Le worker : un transport, pas de la logique
// search.worker.ts
/// <reference lib="webworker" />
import { rankMatches } from './rank-matches';
import type { SearchWorkerInput, SearchWorkerOutput } from './search.messages';
let names: string[] = [];
addEventListener('message', ({ data }: MessageEvent<SearchWorkerInput>) => {
if (data.kind === 'init') {
names = data.names;
return;
}
const response: SearchWorkerOutput = {
id: data.id,
matches: rankMatches(data.query, names),
};
postMessage(response);
});
Toute la logique reste dans rankMatches, une fonction pure que les deux côtés importent du même fichier. Le worker
reçoit, appelle, renvoie - un découpage qui rendra service au moment des tests.
Le service : le worker derrière des signals
// product-search.ts
import { DestroyRef, Injectable, inject, signal } from '@angular/core';
import type { Match } from './rank-matches';
import { PRODUCT_NAMES } from './product-names';
import type { SearchWorkerInput, SearchWorkerOutput } from './search.messages';
@Injectable({ providedIn: 'root' })
export class ProductSearch {
private readonly worker = this.createWorker();
private lastRequestId = 0;
readonly matches = signal<Match[]>([]);
readonly searching = signal(false);
constructor() {
this.worker?.addEventListener('message', ({ data }: MessageEvent<SearchWorkerOutput>) => {
if (data.id !== this.lastRequestId) {
return; // stale response: a newer query is already in flight
}
this.matches.set(data.matches);
this.searching.set(false);
});
this.post({ kind: 'init', names: PRODUCT_NAMES });
inject(DestroyRef).onDestroy(() => this.worker?.terminate());
}
search(query: string): void {
if (!this.worker) {
return; // server render: no results above the fold, the browser takes over
}
this.lastRequestId += 1;
this.searching.set(true);
this.post({ kind: 'search', id: this.lastRequestId, query });
}
private post(message: SearchWorkerInput): void {
this.worker?.postMessage(message);
}
private createWorker(): Worker | undefined {
if (typeof Worker === 'undefined') {
return undefined; // Node has worker_threads, not the Worker global
}
return new Worker(new URL('./search.worker', import.meta.url));
}
}
Trois choix de ce service portent tout le dispositif :
1. Le compteur lastRequestId gère les réponses obsolètes. Un worker dédié est une file : les réponses reviennent
dans l'ordre, mais en retard - celle de "wire" peindrait des résultats que l'utilisateur a déjà dépassés, et
repasserait searching à false pendant que "wireless" calcule encore. L'id sert à jeter le périmé, pas à démêler un
ordre ; il devient indispensable dès que l'ordre n'est plus garanti : un pool de workers, ou un handler async dans
ton worker dédié.
2. La garde typeof Worker === 'undefined' détecte une capacité, pas une plateforme. Oui, l'article qui t'a
appris à refuser un typeof window !== 'undefined' a raison :
"suis-je côté serveur ?" se répond avec isPlatformBrowser. Mais la question ici est "ai-je un Worker sous la
main ?", et PLATFORM_ID n'y répond pas : le global Worker n'existe ni en Node (l'API y est worker_threads), ni sous
jsdom (le DOM simulé de tes tests), où tes specs tournent pourtant avec une plateforme déclarée browser -
isPlatformBrowser te ferait planter en test. Contrepartie réelle : ta suite n'exerce jamais le chemin avec worker
(on y revient).
3. Le retour passe par signal.set(), donc la vue suit. Pas de markForCheck, pas de NgZone.run : le handler
message écrit dans un signal, le graphe réactif fait le reste, en zone.js comme en zoneless (stable depuis
Angular 20.2).
Le composant, réduit à sa vraie responsabilité
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ProductSearch } from './product-search';
@Component({
selector: 'app-product-finder',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input type="search" (input)="onQuery($event)" />
@if (search.searching()) {
<p>Recherche en cours...</p>
}
<ul>
@for (match of search.matches(); track match.name) {
<li>{{ match.name }}</li>
}
</ul>
`,
})
export class ProductFinder {
protected readonly search = inject(ProductSearch);
protected onQuery(event: Event): void {
this.search.search((event.target as HTMLInputElement).value);
}
}
Avant : chaque frappe bloquait le main thread le temps du scoring. Après : la frappe poste un message, le caret et le
rendu restent fluides, les résultats arrivent quand ils arrivent, et searching() couvre l'attente. Le calcul prend
toujours ses 280 ms, mais sur un thread qui n'a rien d'autre à faire. Vérifiable dans le panneau Performance : le bloc
de scripting a déménagé sur la piste du worker - côté main thread, la long task (un bloc de plus de 50 ms pendant
lequel le main thread ne peut ni peindre ni traiter un événement) a disparu.
Les 4 pièges qui t'attendent
1. Le new Worker est analysé statiquement, et c'est l'URL qui compte
Le builder ne suit pas les variables : il repère un new URL('<chemin littéral relatif>', import.meta.url) écrit en
ligne dans l'appel new Worker(...) pour émettre le chunk. Vérifié en 22.0.7 : avec ou sans objet d'options, la
détection tient ; extrais l'URL ou le chemin dans une constante, et elle tombe - pas d'erreur de build, un 404 au
runtime. Un worker qui ne charge pas se débogue donc côté URL, pas côté options.
2. postMessage copie, il ne partage pas
Chaque message est copié entre les threads, dans les deux sens, par l'algorithme structured clone - celui que
structuredClone() expose. Il recopie objets, tableaux, Map, Set, dates et buffers. Une fonction, elle, fait
échouer le postMessage ; une instance de classe passe, mais arrive sans son prototype, en silence : ce qui traverse
est de la donnée nue. Cloner nos 100 000 noms coûte environ 7 ms : marginal face aux 280 ms du scoring, mais tu les
paierais à chaque frappe, et pour rien, si le dataset repartait avec chaque requête. D'où le protocole : les gros
volumes traversent une fois (init), les messages courants restent petits.
Pour du binaire volumineux, il existe mieux que la copie : les objets transférables. postMessage(buffer, [buffer])
déplace un ArrayBuffer d'un thread à l'autre sans clonage - et le rend inutilisable côté émetteur. Un string[]
n'est pas transférable : pour des objets JavaScript ordinaires, la copie est la voie normale.
3. Le worker ne partage presque rien avec ton app
Ni document, ni window, ni injecteur commun, ni signal commun, ni HttpClient configuré par tes providers :
rien de tout cela n'est partagé, l'état de ton app ne voyage que par messages (IndexedDB et le Cache Storage restent
accessibles des deux côtés, localStorage n'existe pas dans un worker, et la mémoire ne se partage que via
SharedArrayBuffer, sur une page cross-origin isolée - un opt-in par en-têtes HTTP).
D'où la règle du transport : logique dans des fonctions pures sans import Angular, worker qui les appelle, service
qui traduit en signals.
4. Sous jsdom, tes tests ne verront pas le worker
jsdom, le défaut du builder de test d'Angular, n'implémente pas les Web Workers : typeof Worker y rend
"undefined", comme en SSR. Le découpage fait plus haut règle l'essentiel :
la logique se teste en appelant rankMatches directement, le service prend la branche sans worker. Le fil complet
composant-worker-signal se vérifie dans un vrai navigateur - pas forcément en end-to-end : l'option browsers du
builder de test (["ChromeHeadless"]) exécute tes specs Vitest dans un vrai Chrome, où Worker existe. Elle exige
playwright ou webdriverio et le @vitest/browser-* assorti : sans eux, le builder échoue au lieu de retomber sur
jsdom.
Les 4 situations où le worker est le mauvais outil
Un calcul pas encore optimisé. Les deux premières pistes du début - rendre le calcul moins cher, le découper - suffisent souvent à repasser sous les 50 ms. Le worker vient après, pas à leur place.
Attendre du réseau. HttpClient est déjà asynchrone : pendant que la requête est en vol, le main thread est
libre. Un await déplacé en worker ne libère rien, il ajoute une couche de messages.
Un calcul court. Créer le worker charge un chunk et démarre un thread, chaque message paie une copie : pour quelques millisecondes de calcul, le remède coûte plus cher que le mal.
Un problème de rendu déguisé. Si le temps part dans les nœuds DOM et le layout, le worker n'y peut rien : le rendu
reste sur le main thread. Un @for de 10 000 lignes se règle avec le CDK Virtual
Scroll, pas avec un thread de plus. Le panneau Performance tranche :
le jaune (scripting) se déporte, le violet (rendering) non.
Récap actionnable
- Le main thread exécute ton JavaScript et met à jour le DOM : 280 ms de calcul synchrone, c'est 280 ms sans
interaction ni mise à jour, quels que soient
OnPush, les signals ou@defer. - Avant le worker : optimise et découpe. Il prend le relais au-dessus de 50 ms.
- Le chunk vient du
new URL('./x.worker', import.meta.url)littéral, en ligne dans lenew Worker; une variable le casse en silence.webWorkerTsConfign'est pas lu par le builderapplication, et le type-check du worker dépend des globs de latsConfigde build :tsc -p tsconfig.worker.json --noEmitcouvre tous les cas. - Trois couches : fonction pure (la logique), worker (le transport), service (les signals). Le composant ignore qu'un worker existe.
- Le dataset traverse une fois (
init), les requêtes portent un id, les réponses en retard sont ignorées. typeof Worker === 'undefined'détecte une capacité (absente en Node comme sous jsdom), pas une plateforme ; la branche worker se vérifie dans un vrai navigateur (optionbrowsersdu builder de test, avec Playwright ou WebdriverIO installé).- Rien à gagner sur du réseau, un calcul court ou un problème de rendu : le panneau Performance distingue scripting (jaune, déportable) et rendering (violet, non).
Un Web Worker n'accélère rien : ton scoring prend le même temps qu'avant. Il déplace ce temps là où personne ne le voit, et c'est exactement ce qu'une interface doit à l'utilisateur.