~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 le new Worker ; une variable le casse en silence. webWorkerTsConfig n'est pas lu par le builder application, et le type-check du worker dépend des globs de la tsConfig de build : tsc -p tsconfig.worker.json --noEmit couvre 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 (option browsers du 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.

📧 Reste informé(e) !

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

S'inscrire gratuitement

AngularKit

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

Produits

Contact

Légal

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