DevGuide

Web Workers en 2026 : patterns, pièges et INP

Sur cette page
  1. Pourquoi les Web Workers comptent plus en 2026 qu'en 2022
  2. Les trois types de worker et quand utiliser chacun
  3. Pattern 1 : décharger le calcul lourd
  4. Pattern 2 : traitement de données en streaming
  5. Pattern 3 : OffscreenCanvas pour le travail graphique
  6. Communication : postMessage, transferable, SharedArrayBuffer
  7. Pourquoi Comlink vaut bien ses 2 Ko
  8. Mesurer l'impact sur INP et TBT
  9. Les erreurs courantes qui annulent le bénéfice
  10. Sources et lectures complémentaires

Les Web Workers en 2026, c'est la solution ennuyeuse qui n'enthousiasme personne, et c'est précisément pour ça qu'ils comptent maintenant. Tu balances le gros du travail sur un autre thread, le clic reste fluide, et ton INP glisse sous la ligne verte des 200 ms pendant que le crawler approuve en silence. Les Web Workers sont dans les navigateurs depuis 2009, mais dès qu'INP est devenu un Core Web Vital, toute tâche JavaScript qui squatte le main thread au-delà de 50 millisecondes a cessé d'être un agacement pour devenir un chiffre sur lequel Google te note. Voilà le plan : les patterns que je sors vraiment, ceux que j'ai appris à éviter, les parties pénibles de la communication entre threads, et comment prouver que le changement a servi à quelque chose.

The short answer

Déplace le gros du JavaScript synchrone hors du main thread avec un Dedicated Worker pour que les clics restent fluides et que l'INP descende sous la ligne verte. Sors postMessage avec des transferable objects (ArrayBuffer, streams, OffscreenCanvas) pour éviter la taxe de clonage, enveloppe les workers multi-méthodes dans Comlink, et prouve le gain avec le Total Blocking Time dans les DevTools plus les données terrain onINP. Oublie le worker pour un travail de moins de 4 millisecondes environ.

200 mscible INP que Google note
< 4 msen dessous, oublie le worker
2 KoComlink, gzippé
Carte réponse : décharge le gros du JavaScript sur un Dedicated Worker pour que le main thread reste libre et que l'INP atterrisse sous 200 ms.
Toute l'idée en une carte : le gros du travail quitte le main thread, donc le clic reste fluide et l'INP reste vert. PNG

Les Web Workers sont dans les navigateurs depuis 2009. Pendant la majeure partie de ce temps, je les ai traités comme une curiosité, un truc dont j'avais lu l'existence sans jamais y toucher. Et puis INP a remplacé First Input Delay parmi les Core Web Vitals, et toute tâche JavaScript qui squatte le main thread au-delà de 50 millisecondes a cessé d'être un simple agacement. C'est devenu un chiffre sur lequel Google te note. Les Workers, c'est la solution ennuyeuse qui n'enthousiasme personne. Tu balances le gros du travail sur un autre thread, le clic reste fluide, et ton INP glisse sous la ligne verte des 200 ms pendant que le crawler approuve en silence. Voilà le plan, donc. Les patterns que je sors vraiment en 2026, ceux que j'ai appris à éviter, les parties pénibles de la communication entre threads (transferable objects, SharedArrayBuffer, Comlink), et comment prouver que le changement a servi à quelque chose au lieu de juste donner l'impression d'aller plus vite.

Pourquoi les Web Workers comptent plus en 2026 qu'en 2022

Plusieurs choses ont bougé depuis 2022, et mises bout à bout elles ont sorti les Workers de la pile du « ce serait bien d'avoir ça ». Commençons par INP, devenu un Core Web Vital officiel en mars 2024. Il chronomètre l'interaction la plus lente qu'un vrai utilisateur a subie sur ta page, l'aller-retour complet, y compris tout le JavaScript qui s'est déclenché quand il a tapé. Dépasse 200 millisecondes et Google range ce tap dans la case « médiocre ». Donc à la seconde où ton bouton de filtre parse un blob JSON de 2 Mo en synchrone, tu as offert au moteur de recherche un chiffre de latence qu'il pourra agiter à côté de ceux de tes concurrents. Pas terrible.

Et puis il y a tout ce qu'on entasse désormais dans le navigateur. L'édition d'images dans l'onglet. L'inférence ML on-device, la génération de PDF, le fait de mouliner un CSV pour un tableau de plusieurs milliers de lignes, le chiffrement de bout en bout pour le chat, l'estimation de pose en AR. En 2026 les utilisateurs s'attendent juste à ce que tout ça tourne en local. Pas d'aller-retour vers le backend, pas de spinner. Et chacune de ces opérations, laissée sur le main thread, c'est un gel de plusieurs secondes. J'ai déjà regardé un appel de chiffrement côté client soi-disant « rapide » bloquer une UI pendant quatre secondes sur un téléphone milieu de gamme. Quatre secondes. L'utilisateur est déjà parti à ce stade.

Le troisième point est plus discret, mais c'est la raison pour laquelle tout ça est agréable aujourd'hui. Les navigateurs ont mûri. SharedArrayBuffer fonctionne dès que tu envoies les bons en-têtes COOP et COEP. OffscreenCanvas est livré dans tous les navigateurs evergreen, et les API autour des Workers ont enfin arrêté de me surprendre à 23h. Le travail nécessaire pour en câbler un ne cesse de diminuer pendant que le gain ne cesse de grimper, ce qui est une façon détournée de dire que le calcul penche en faveur des Workers dans bien plus de cas qu'avant.

Les trois types de worker et quand utiliser chacun

Tu as trois variantes. Honnêtement, tu passeras le plus clair de ton temps avec une seule d'entre elles. Le Dedicated Worker est le cheval de trait. Un par page, il ne parle qu'à celui qui l'a créé. C'est lui qu'il te faut dès qu'une action utilisateur lance quelque chose de lourd, ou que tu as du calcul de fond à faire sur une seule page. Les Shared Workers tournent en une seule instance partagée par tous les onglets de la même origine, et tu leur parles via un port. Ça sonne génial pour de l'état partagé, disons un WebSocket que plusieurs onglets veulent écouter. Mais l'API est assez capricieuse pour que j'aie vu des gens abandonner et basculer sur Broadcast Channel, et je ne leur en veux pas. Les Service Workers sont une autre bête. Ils se placent entre le réseau et ta page pour intercepter les fetches, et ils restent là après la disparition de la page. C'est ton outil pour le cache offline et la synchro en arrière-plan.

Pour le travail de perf de ce guide ? Dedicated Worker, quasiment à chaque fois. De temps en temps avec un Service Worker greffé pour la synchro en arrière-plan. Les Shared Workers, tu peux à peu près oublier qu'ils existent. C'est mon cas. Ils ne m'ont pas manqué.

Pattern 1 : décharger le calcul lourd

C'est celui que tu utiliseras dans quatre-vingt-dix pour cent des cas. Un gros bloc de travail synchrone, du parsing, du hashing, un filtre d'image ou un calcul scientifique, est posé sur le main thread et bloque les entrées et les frames d'animation tant qu'il tourne. Confie-le à un worker et la page respire à nouveau. La version de départ est minuscule. Moins de 30 lignes :

// worker.js
self.onmessage = (e) => {
  const result = expensiveCompute(e.data);
  self.postMessage(result);
};

function expensiveCompute(input) {
  // CPU-heavy work here
  return input.map(x => slowTransform(x));
}

// main.js
const worker = new Worker('/worker.js');
worker.onmessage = (e) => {
  renderResult(e.data);
};
worker.postMessage(inputArray);

Règle approximative que je suis : si le travail dure moins de 4 millisecondes environ, ne te casse pas la tête. Le démarrage du worker (1-2 ms) plus la taxe de sérialisation du postMessage bouffe juste ce que tu avais gagné. Et si tu l'appelles encore et encore, construis le worker une seule fois au chargement de la page, puis réutilise-le. Lancer un nouveau worker à chaque clic est une manière classique de ralentir les choses tout en se croyant malin.

Pattern 2 : traitement de données en streaming

Quand l'entrée est grosse, un CSV de plusieurs mégaoctets, un dump JSON d'une API paginée, un flux de frames vidéo, ne balance pas tout d'un coup à travers la frontière du postMessage. Fais-le passer par un ReadableStream et laisse le worker le mâcher morceau par morceau. En 2026, les Streams dans les workers fonctionnent partout, donc il n'y a plus d'excuse de compatibilité derrière laquelle se cacher.

// main.js
const response = await fetch('/api/large-data.json');
const worker = new Worker('/parse-worker.js');
worker.postMessage({ stream: response.body }, [response.body]);

// parse-worker.js
self.onmessage = async (e) => {
  const reader = e.data.stream.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    processChunk(value);
  }
  self.postMessage({ status: 'complete' });
};

Ce petit [response.body], c'est la liste des transferable, et c'est facile de passer dessus sans le voir. Une fois que tu as cédé le stream, le main thread ne peut plus y toucher. La propriété a vraiment changé de main, elle est passée au worker. C'est tout l'intérêt. Rien n'est copié, donc c'est la façon la moins chère de faire traverser un stream d'un thread à l'autre. Essaie de le lire côté main thread après coup et tu auras une erreur déroutante. Souviens-toi juste qu'il est parti.

Pattern 3 : OffscreenCanvas pour le travail graphique

Le travail sur canvas était autrefois une plaie d'un genre particulier. Les filtres d'image et le rendu de graphiques dessinaient en synchrone sur le main thread, pareil pour une viz qui trace des milliers de points, donc la page se bloquait pendant qu'elle peignait. OffscreenCanvas a enfin réglé ça pour moi. Il confie le rendu à un worker et laisse le main thread libre, donc les clics continuent d'aboutir pendant que le gros du dessin se passe ailleurs.

// main.js
const canvas = document.getElementById('plot');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('/render-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);

// render-worker.js
self.onmessage = (e) => {
  const ctx = e.data.canvas.getContext('2d');
  // Heavy drawing loop runs in the worker
  drawScene(ctx);
};

Là où ça brille vraiment, c'est sur les graphiques. J'avais un dashboard qui rendait quarante plots, et pousser chaque plot sur son propre worker m'a rapproché d'une accélération linéaire sur une machine multicœur. Le hic, et il y en a toujours un, c'est qu'il n'y a pas de DOM dans un worker. Du coup tes tooltips et tes gestionnaires de clic vivent toujours sur le main thread, et relisent les coordonnées que le worker a calculées et envoyées par message. Ça marche. C'est juste un peu plus de câblage que ce que tu espérerais.

Communication : postMessage, transferable, SharedArrayBuffer

Parler à un worker passe par postMessage et l'algorithme de structured clone. Par défaut, tout ce que tu passes est cloné en profondeur. Une copie complète, à chaque fois. Pour de petits payloads façon JSON, on s'en fiche, c'est instantané. Pour des données binaires à l'échelle du mégaoctet, cette copie peut te ruiner. Je l'ai vue ajouter plus de latence que le travail qu'elle était censée accélérer. Il y a quelques portes de sortie.

Transferable objects

Une poignée de types peuvent être transférés au lieu d'être copiés : ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, les types de stream. La propriété saute de l'autre côté sans aucune copie, et ta référence d'origine meurt. Tu les listes en second argument de postMessage :

const buffer = new ArrayBuffer(10 * 1024 * 1024); // 10 MB
worker.postMessage({ data: buffer }, [buffer]);
// buffer.byteLength is now 0 on the main thread

Pour un pipeline binaire, c'est l'écart entre un transfert de 12 ms (cloner 10 Mo) et un transfert de 0,1 ms (juste déplacer un pointeur). Utilise-le à chaque fois que tu peux.

Comparaison : cloner 10 Mo à travers postMessage coûte environ 12 ms, alors que transférer le même ArrayBuffer coûte environ 0,1 ms.
Mêmes 10 Mo, deux façons de traverser la ligne entre threads. Le clonage copie chaque octet. Le transfert ne fait que déplacer le pointeur. PNG

SharedArrayBuffer

Quand le main thread et le worker ont vraiment besoin de lire et d'écrire la même mémoire en même temps, SharedArrayBuffer est la façon de le dire à voix haute. Associe-le à l'API Atomics et tu as de vraies structures de données multi-threadées. Mais ça te coûte à l'entrée. Ton site doit servir Cross-Origin-Opener-Policy: same-origin et Cross-Origin-Embedder-Policy: require-corp. Et le jour où tu actives ça, chaque asset cross-origin (images de CDN, ce clip YouTube embarqué) doit s'inscrire via CORS ou renvoyer le bon en-tête CORP, sinon il arrête tout simplement de charger. Celui-là m'a déjà gâché un après-midi. Donc voilà mon avis, et c'est peut-être juste moi qui me suis fait avoir une fois de trop : ne sors pas SharedArrayBuffer à moins que l'alternative, à savoir matraquer des transferable dans les deux sens, soit mesurablement pire. La plupart du temps, elle ne l'est pas.

Faire du postMessage à la main avec des handlers onmessage devient vite bruyant. Et apparier les réponses aux requêtes à la main, c'est exactement le genre de comptabilité que je déteste. Comlink (de chez Google, environ 2 Ko gzippé) enveloppe le worker dans un Proxy pour que tu appelles ses méthodes comme de simples fonctions async sur le main thread. C'est tout l'argument. Et c'est un bon.

// worker.js
import * as Comlink from 'comlink';
const api = {
  parse(csv) { return parseCsv(csv); },
  filter(rows, predicate) { return rows.filter(predicate); }
};
Comlink.expose(api);

// main.js
import * as Comlink from 'comlink';
const worker = new Worker('/worker.js', { type: 'module' });
const api = Comlink.wrap(worker);
const rows = await api.parse(csvString);
const filtered = await api.filter(rows, Comlink.proxy(r => r.age > 18));

Ce que tu récupères se lit comme une API async ordinaire. Pas de plomberie postMessage, pas d'ids à coller sur les requêtes pour les rapparier plus tard. Sous le capot, le coût de sérialisation est identique au postMessage brut, donc tu ne paies rien pour ce confort. Dès qu'un worker expose plus d'une opération, je démarre directement avec Comlink et je ne reviens pas en arrière.

Mesurer l'impact sur INP et TBT

L'erreur que je vois plus que toute autre, c'est livrer le worker et ne jamais vérifier s'il a aidé. Ça donne une impression de rapidité. La page paraît plus réactive, quelqu'un tape dans la main d'un collègue, et la métrique n'a pas bougé d'un millimètre. Donc traite ces mesures comme ton contrat « ça a vraiment marché », et ne le signe pas trop tôt.

  1. Mesure en labo avec le panneau Performance de Chrome DevTools. Enregistre la même interaction avant et après l'ajout du worker. Le chiffre du Total Blocking Time dans le résumé est ta comparaison à conditions égales. Et regarder cette grosse long task rétrécir dans le flame chart est honnêtement la partie la plus satisfaisante du boulot.
  2. Mesure terrain avec la bibliothèque JavaScript web-vitals. Branche onINP dans ton analytics et compare l'INP p75 sur les 28 jours de part et d'autre du déploiement. Le rapport Page Experience de la Search Console tourne sur des données terrain, pas sur ton portable. C'est donc le chiffre qui fait bouger ton classement.
  3. Long Tasks API en production. Abonne-toi aux entrées longtask avec PerformanceObserver et logge-les quelque part où tu iras vraiment regarder. Avant le changement, tu verras un filet régulier de tâches au-dessus de 50 ms. Après, cet histogramme devrait glisser vers la gauche. Si ce n'est pas le cas, ton worker n'est pas le goulot d'étranglement et tu as réparé le mauvais truc.

Petit avertissement. La première fois que tu câbles tout ça, le worker a souvent moins aidé que tu l'espérais, parce qu'autre chose bloquait le main thread tout du long. Un script tiers, en général. Ou un vilain layout thrash. Ce n'est pas un échec. C'est la mesure qui fait son travail. Tu corriges le vrai coupable, tu remesures, et tu refais le tour de la boucle autant de fois qu'il faut.

Les erreurs courantes qui annulent le bénéfice

  • Petits payloads, allers-retours répétés. Envoie 50 messages par seconde de payloads de 100 octets et l'overhead du postMessage te coûte discrètement plus que ce que le worker t'a jamais fait gagner. Regroupe-les. Ou alors n'utilise simplement pas de worker ici.
  • Créer un worker par interaction. En lancer un prend 1-2 ms sur un bon portable et jusqu'à 20 ms sur un Android fatigué. Construis-le une fois au chargement de la page. Ensuite tu n'y penseras plus jamais.
  • Oublier les transferable. Clone un Uint8Array de 5 Mo à travers postMessage et tu viens de t'acheter 6 ms de latence pour rien. Transfère plutôt l'ArrayBuffer sous-jacent. Mêmes données, coût quasi nul.
  • Fuites mémoire via l'état global. Un worker qui continue d'empiler les résultats dans un tableau de niveau module grossit jusqu'à ce que l'onglet meure, parce que personne ne lui a jamais dit de lâcher. Envoie-lui un message de reset explicite et épargne-toi la séance de 3h du matin à fixer le graphe de mémoire.
  • Terminaison synchrone du worker à la navigation. Appeler worker.terminate() dans un handler beforeunload n'attendra pas les messages encore en vol. Il les guillotine, c'est tout. Si le worker était en plein background-save, ces données sont perdues. Utilise la background sync d'un Service Worker pour tout ce qui doit survivre.
  • Logger dans une boucle serrée à l'intérieur du worker. Chaque ligne de console repart vers le main thread et fabrique exactement le travail de main thread que tu essayais d'éviter. Retire console.log de ton build de production.

Sources et lectures complémentaires

Questions fréquentes

Les Web Workers valent-ils la complexité pour un petit site ?

Honnêtement ? Si c'est un site vitrine sans gros travail côté client, ne te casse pas la tête. Tu ajouterais de la complexité pour rien. Mais à la seconde où ton appli se met à parser des données, mouliner des images, filtrer une grosse recherche ou tracer des graphiques dans le navigateur, là oui. Ça gagne sa place. Ma limite approximative, c'est environ 50 ms de travail par interaction. Au-delà, un worker commence à te rembourser.

Les Web Workers aident-ils sur le First Contentful Paint ?

Pas vraiment. Ou alors seulement de biais. Le FCP, c'est la vitesse à laquelle le premier pixel significatif apparaît, et ça dépend de ton réseau et du JavaScript render-blocking, des trucs auxquels un worker ne touche pas. Les workers gagnent leur argent après le FCP, une fois la page affichée et qu'un gros script s'apprête à la geler. Si c'est le FCP qui fait mal, regarde d'abord le code splitting et les resource hints. C'est là qu'est le gain.

Puis-je utiliser des modules ES dans un Worker en 2026 ?

Oui, et c'est un délice. Passe `{ type: 'module' }` au constructeur Worker et écris juste des instructions `import` normales dans le fichier du worker. Tous les navigateurs evergreen le gèrent maintenant. La seule chose qui te fera trébucher, ce n'est pas le navigateur. C'est ton bundler. Assure-toi que ce que tu utilises (Vite, ou Webpack 5+, ou Rollup) est configuré pour émettre correctement les bundles de worker, parce qu'un mal configuré échoue de façons franchement pénibles à diagnostiquer.

Devrais-je partager un pool de workers ou en instancier un par tâche ?

Mets-les en pool. Le pattern sur lequel je reviens toujours : 2-4 workers (cale-toi sur navigator.hardwareConcurrency, plafonné à 4) derrière une file de tâches, avec un dispatcher en round-robin qui distribue le travail. Pas envie d'écrire ça toi-même ? La bibliothèque `workerpool` le fait en environ 5 Ko. Cela dit, si tu n'as qu'un seul type de tâche, un seul worker suffit vraiment. Ne sur-ingénie pas la chose.

Quel est le nombre maximum de workers que je devrais lancer ?

Plafonne à `navigator.hardwareConcurrency` moins un. Laisse ce dernier cœur au main thread pour que l'UI reste vivante. Plein de téléphones d'entrée de gamme ne rapportent que 2 cœurs au total, ce qui veut dire qu'en pratique tu es souvent sur 1 à 3 workers, pas la douzaine que tu imaginais. Dépasse ce que le matériel a et l'OS passe son temps à jongler entre eux. Tu passes plus de temps à changer de contexte qu'à calculer, et tout devient plus lent.