Accueil / Articles / Workers dédiés, partagés et de service : choisir le bon thread navigateur

Workers dédiés, partagés et de service : choisir le bon thread navigateur

Comprenez en quoi les travailleurs dédiés, partagés et de service diffèrent par leur portée, leurs messages et leur objectif, et apprenez quand chacun d’eux améliore réellement une application web.

2425 mots

La page JavaScript s’exécute sur un seul thread principal, pourtant les applications modernes restent réactives tout en traitant de gros fichiers, en fonctionnant hors ligne et en recevant des notifications push même lorsqu’elles sont inactives. Une grande partie de cette capacité provient des workers, qui exécutent des scripts en dehors du thread principal. Cependant, le terme « worker » désigne trois outils différents, et choisir le mauvais entraîne des pertes de temps. Ce guide explique à quoi sert chaque type, comment ils communiquent et dans quels cas il ne vaut pas la peine de les utiliser.

Cette famille compte trois membres :

Web Workers
     │
     ├── Dedicated Worker
     │
     ├── Shared Worker
     │
     └── Service Worker

Pourquoi le thread principal a besoin d’aide

Par défaut, votre code partage un seul thread avec les mises à jour du DOM, le traitement des entrées, l’affichage et les animations :

JavaScript
   ↓
DOM updates
   ↓
User interactions
   ↓
Rendering
   ↓
Animations

Comme ces tâches s’alternent, une appel synchrone long comme celui ci-dessous bloque le thread jusqu’à son retour :

const result = expensiveCalculation();

Jusqu’à ce qu’il soit terminé, le navigateur ne peut pas gérer les entrées ni afficher une frame. L’utilisateur ressent cela :

User clicks button
        ↓
Heavy JavaScript starts
        ↓
Main thread is busy
        ↓
UI becomes sluggish
        ↓
Calculation finishes
        ↓
UI becomes responsive again

Un worker résout ce problème en exécutant du JavaScript dans son propre contexte séparé, permettant ainsi au thread principal de rester disponible pour les tâches liées à l’interface.

Comment une page et un worker coopèrent

Imaginez deux threads reliés par un canal de messages : le thread principal gère l’UI, le DOM, les événements et l’affichage, tandis que le worker s’occupe des calculs, du parsing et du traitement :

                 Browser
                    │
          ┌─────────┴─────────┐
          │                   │
          ↓                   ↓
     Main Thread          Worker Thread
          │                   │
     UI / DOM             Heavy Work
     Events               Calculations
     Rendering             Parsing
     Interaction           Processing
          │                   │
          └──── Messages ─────┘

La page envoie des données vers un worker en appelant postMessage sur l’objet worker :

worker.postMessage(data);

Dans le worker, la fonction globale postMessage envoie le résultat vers la page :

postMessage(result);

Les deux parties ne partagent aucune variable ordinaire ; tout est transmis sous forme de message.

Pourquoi les workers ne peuvent pas accéder au DOM

Le scope global d’un worker n’a pas accès à ces éléments :

document
window
DOM elements

Une ligne de ce type à l’intérieur d’un fichier worker échoue simplement, car document n’est pas défini là :

// worker.js

document.querySelector("#app");

La méthode appropriée consiste plutôt à effectuer le calcul dans le worker, à envoyer le résultat, puis à laisser le thread principal l’appliquer à la page :

Main Thread
     │
     │ postMessage(data)
     ↓
   Worker
     │
     │ performs calculation
     ↓
   Worker
     │
     │ postMessage(result)
     ↓
Main Thread
     │
     ↓
Update DOM

Conserver la propriété du DOM sur un seul thread signifie que le code en arrière-plan ne peut jamais modifier l’interface au cours du rendu.

Les trois types de workers en bref

┌──────────────────────────────┐
│        Web Workers           │
├──────────────────────────────┤
│                              │
│  Dedicated Worker            │
│  → One script/client         │
│                              │
│  Shared Worker               │
│  → Multiple same-origin      │
│    browsing contexts         │
│                              │
│  Service Worker              │
│  → Network / caching /       │
│    offline / background      │
│    capabilities              │
│                              │
└──────────────────────────────┘

Workers dédiés aux calculs lourds

const worker = new Worker("worker.js");
worker.postMessage(1000000);
worker.onmessage = (event) => {
    console.log("Result:", event.data);
};

Le travailleur écoute les messages, additionne tous les entiers inférieurs à la valeur reçue et répond avec le total :

// worker.js

self.onmessage = (event) => {
    const number = event.data;
    let result = 0;
    for (let i = 0; i < number; i++) {
        result += i;
    }
    self.postMessage(result);
};

Le aller-retour est simple :

Main Thread
     │
     │ 1000000
     ↓
Dedicated Worker
     │
     │ Calculate
     ↓
   Result
     │
     ↓
Main Thread

Le travailleur est lié à son créateur et n’est jamais partagé avec des pages non apparentées ; deux onglets disposent de deux travailleurs indépendants.

Bons candidats pour un travailleur dédié

Ils se révèlent utiles pour les tâches gourmandes en CPU au sein d’une même application. La transformation d’un gros chargement API est typique : le travailleur restructure les données tandis que le thread principal se contente de les afficher.

API Response
     ↓
Large JSON
     ↓
   Worker
     ↓
Parse / Transform
     ↓
Main Thread
     ↓
Render UI

La redimensionnement et le filtrage d’images suivent la même logique :

Image
  ↓
Worker
  ↓
Resize / Transform
  ↓
Result
  ↓
 UI

Il en va de même pour le filtrage, le tri et l’agrégation de grands ensembles de données :

Large Dataset
      ↓
    Worker
      ↓
  Filtering
   Sorting
 Aggregation
      ↓
     UI

L’encodage, la compression, l’analyse de fichiers volumineux et les calculs intensifs conviennent également. Règle générale : si des tâches gourmandes en CPU ralentissent l’interface, envisagez un travailleur dédié.

Workers partagés pour plusieurs onglets

Supposons maintenant que la même application soit ouverte dans plusieurs contextes de navigation en même temps :

Tab A
Tab B
Tab C

Un worker partagé permet aux scripts de plusieurs fenêtres, onglets ou iframes de se connecter à un seul worker, à condition qu’ils partagent la même origine :

             Shared Worker
              /    |    \
             /     |     \
          Tab A   Tab B   Tab C

Sans lui, chaque onglet crée sa propre copie :

Tab A → Worker A
Tab B → Worker B
Tab C → Worker C

Avec lui, chaque onglet se connecte à une seule instance :

Tab A ─┐
Tab B ─┼──> Shared Worker
Tab C ─┘

Communication via des ports

On accède à un worker partagé par l’intermédiaire d’un MessagePort explicite. La page lance ce port et envoie un message :

const worker = new SharedWorker("worker.js");
worker.port.start();
worker.port.postMessage("Hello");

Chaque nouveau client déclenche un événement connect dans le worker, dont le gestionnaire écoute sur le port de ce client :

self.onconnect = (event) => {
    const port = event.ports[0];
    port.onmessage = (event) => {
        console.log(event.data);
    };
};

Le port représente la principale différence pratique par rapport à un travailleur dédié : avec de nombreux clients, chaque connexion nécessite son propre canal, et les réponses reviennent via le port de la bonne fenêtre.

Un scénario à plusieurs fenêtres

Imaginez un outil interne où différentes fenêtres affichent des vues distinctes :

Tab 1 → Dashboard
Tab 2 → Reports
Tab 3 → Analytics

Un seul composant en arrière-plan pourrait gérer l’état commun ou coordonner la communication entre toutes les fenêtres :

              Shared Worker
                   │
        ┌──────────┼──────────┐
        ↓          ↓          ↓
     Tab 1       Tab 2      Tab 3
        │          │          │
        └──────────┼──────────┘
                   ↓
             Shared State

Cela permet d’éviter à chaque fenêtre d’avoir sa propre instance de travailleur, mais tous les contextes doivent partager une origine. La prise en charge des travailleurs partagés a également historiquement été en retard par rapport aux travailleurs dédiés, notamment sur certains navigateurs mobiles ; vérifiez donc d’abord les données de compatibilité actuelles.

Travailleurs de service pour le fonctionnement en réseau et hors ligne

Le travailleur de service est le type le plus mal compris. Ce n’est pas un travailleur dédié rebaptisé ; il se situe entre votre application, le navigateur et le réseau :

Browser
   │
   ↓
Service Worker
   │
   ├── Cache
   │
   ├── Network
   │
   ├── Offline response
   │
   └── Background capabilities

Comme il intercepte les requêtes, il permet des fonctionnalités hors ligne, du cacheage, un traitement personnalisé des requêtes, des notifications push et une synchronisation en arrière-plan.

Entre la page et le serveur

Imaginons qu’un utilisateur visite votre site :

https://example.com

En l’absence d’un service worker, chaque requête est envoyée directement depuis le navigateur vers le serveur :

Browser
   ↓
Internet
   ↓
Server

Avec un service worker enregistré, les requêtes passent d’abord par lui, qui peut les servir depuis le cache ou les rediriger vers le réseau :

Browser
   ↓
Service Worker
   ↓
   ├── Cache
   │
   └── Network

Celui-ci décide du sort de chaque requête. Une stratégie basée sur le cache permet de répondre immédiatement aux requêtes déjà stockées, sinon d’effectuer la récupération et de les mettre en cache le cas échéant :

Request
   ↓
Is it cached?
   │
   ├── YES → Return cached response
   │
   └── NO
       ↓
     Network
       ↓
     Response
       ↓
     Cache if appropriate

Les applications fonctionnant hors ligne sont construites sur cette logique. Pour un guide pas à pas, consultez comment activer le support hors ligne dans les applications web avec des service workers.

Enregistrement et cycle de vie

Register
   ↓
Download
   ↓
Install
   ↓
Activate
   ↓
Control Pages

if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js");
}

new Worker(...)

localhost autorisé en développement). Un nouveau worker ne contrôle pas les pages déjà ouvertes tant qu’elles n’ont pas été rechargées, à moins qu’il ne les réclame, ce qui confond souvent les tests.

Interception des requêtes

fetch ; celui-ci se contente de journaliser chaque URL :

self.addEventListener("fetch", (event) => {
    console.log("Request:", event.request.url);
});

app.js : servir depuis le cache s’il est présent, sinon récupérer, stocker et renvoyer :

User requests app.js
        ↓
Service Worker
        ↓
Is app.js cached?
     /       \
   YES        NO
    ↓          ↓
 Return      Network
 Cache          ↓
              Cache
                ↓
             Return

C’est pourquoi ils jouent un rôle central dans les applications web progressives (PWAs) et les designs optimisés pour le fonctionnement hors ligne.

Comparaison des trois côte à côte

Worker dédié

En bref, c’est « un thread en arrière-plan appartenant à une seule page » :

 Page
  │
  ↓
Dedicated Worker

Il convient aux calculs gourmands en CPU, au parsing, au traitement d’images et au traitement de données.

Worker partagé

En bref, c’est « un worker que plusieurs pages du même origin peuvent utiliser » :

Tab A ─┐
Tab B ─┼──> Shared Worker
Tab C ─┘

Il convient aux tâches en arrière-plan partagées entre différents contextes de navigation, ainsi qu’à la communication ou à l’état partagé.

Service worker

En bref, c’est « une couche entre l’application et le réseau » :

App
 ↓
Service Worker
 ↓
Cache / Network

Il convient aux applications hors ligne, au stockage en cache, à l’interception des requêtes, aux notifications push et à la synchronisation en arrière-plan.

Résumé en une ligne de chacun

En une seule ligne pour chacun :

Dedicated Worker
        ↓
"Do this heavy computation for me."

Shared Worker
        ↓
"Let multiple pages use this worker."

Service Worker
        ↓
"Help my web application interact with
the network and browser capabilities."

Presenté de cette manière, il est difficile de confondre ces trois approches.

Portée, messagerie et utilisation typique

Dédicacé : un seul script, calcul en arrière-plan, postMessage(), pas de DOM :

Scope:
One script

Main purpose:
Background computation

Communication:
postMessage()

DOM access:
❌ No

Common use:
Heavy computation

Partagé : plusieurs contextes de même origine, MessagePort, pas de DOM, coordination entre onglets :

Scope:
Multiple same-origin contexts

Main purpose:
Shared background work

Communication:
MessagePort

DOM access:
❌ No

Common use:
Cross-tab/shared worker communication

Service : pages dans son propre domaine et portée de chemin, événements et API de plateforme, pas de DOM, mise en cache, fonctionnement hors ligne, fonctionnalités push et synchronisation :

Scope:
Origin/path controlled pages

Main purpose:
Network + background capabilities

Communication:
Events / messaging / APIs

DOM access:
❌ No

Common use:
Caching, offline, push, background sync

Lorsqu’un worker ralentit les choses

Les workers ne sont pas automatiquement plus rapides. Leur démarrage coûte de l’énergie, et les données doivent être transférées entre les contextes :

 Main Thread
      ↕
    Worker

Cette messagerie prend également du temps. Pour une tâche simple, les coûts supplémentaires peuvent dépasser la complexité de la tâche elle-même :

Small Task
   ↓
Worker overhead
   ↓
Communication
   ↓
Actual calculation

Le code exécuté par un worker peut alors être plus lent que le code en ligne. N’en utilisez un que lorsque la tâche est suffisamment lourde pour que son déplacement vers le worker apporte un gain visible.

Qu’est-ce qui traverse réellement la frontière

Les messages sont copiés à l’aide de l’algorithme de clonage structuré, ce qui fait que des charges utiles importantes augmentent le temps de copie. Les objets transférables tels que ArrayBuffer sont déplacés sans être copiés, mais l’expéditeur perd alors accès à eux. SharedArrayBuffer ne permet un véritable partage de mémoire que sous réserve de exigences de sécurité supplémentaires (isolation entre origines). Pour la plupart des applications, privilégiez les messages explicites :

Main Thread
    ↓
postMessage()
    ↓
  Worker
    ↓
postMessage()
    ↓
Main Thread

Utilisation d’un worker dédié dans React

Lorsqu’une application React traite un grand ensemble de données pendant le rendu ou dans un gestionnaire d’événements, l’interface utilisateur stagne :

React UI
   ↓
Large calculation
   ↓
Main thread blocked
   ↓
UI becomes sluggish

Avec un worker dédié, le composant envoie des données et met à jour l’état dès que le résultat arrive :

React UI
   │
   ├──────────────> Worker
   │                  │
   │                  ↓
   │              Calculation
   │                  │
   │                  ↓
   │<──────────── Result
   │
   ↓
Update UI

React continue de se rendre tant que le worker effectue des calculs. Créez le worker dans un effet et appelez terminate() lors de sa nettoyage afin qu’il ne survive pas au composant. Cela convient à la visualisation de données, aux éditeurs d’images, au traitement audio et vidéo, aux gros fichiers ainsi qu’à l’analyse côté client.

Choisir le bon worker

Les applications JavaScript gourmandes en CPU nécessitent un worker dédié :

Do you have CPU-heavy JavaScript?
          │
         YES
          ↓
   Use Dedicated Worker

Si les exigences sont les suivantes :

Multiple tabs/pages need
the same worker

alors le type à évaluer est :

Shared Worker

Et si les exigences impliquent l’un de ces éléments :

Caching
Offline support
Network interception
Push notifications
Background sync

alors la réponse est :

Service Worker

Erreurs courantes

Mettre tout dans un worker

Utilisez les workers uniquement lorsque cela résout un problème mesurable.

Esperer avoir accès au DOM

Cela ne fonctionne pas :

worker.document.querySelector(...);

Les travailleurs envoient des données en retour ; le thread principal met à jour le DOM.

Utilisation d’un service worker pour les calculs

Les service workers s’occupent du réseau et du cycle de vie de l’application, et le navigateur peut les arrêter lorsqu’ils sont inactifs. Pour des calculs purement numériques tels que :

1 million records
       ↓
complex calculation

Commencez par un travailleur dédié.

Négliger les coûts de messagerie

Chaque échange comporte des frais supplémentaires :

Main Thread
     ↕
Messaging
     ↕
  Worker

Ne transférez que les données suffisamment volumineuses pour justifier le transfert.

Conclusion

Principe directeur : le JavaScript coûteux ne doit pas bloquer le thread principal sans raison. Ces trois types correspondent à trois problèmes distincts :

                    Web Workers
                         │
          ┌──────────────┼──────────────┐
          ↓              ↓              ↓
     Dedicated         Shared         Service
       Worker          Worker          Worker
          │              │              │
     One script      Multiple        Network /
     uses it         contexts        offline
  • Travailleur dédié : calculs lourds pour une seule page.
  • Travailleur partagé : tâches en arrière-plan partagées par plusieurs contextes de même origine.
  • Service worker : interception du réseau, mise en cache et fonctionnalités hors ligne.
  • Au préalable de son adoption, mesurez la tâche gourmande en ressources, assurez-vous qu’elle l’emporte sur les coûts liés aux messages, et vérifiez la compatibilité des navigateurs. Vus de cette manière, les service workers constituent trois solutions concrètes à trois questions distinctes.

    Lectures complémentaires