HTTP, c’est une question puis une réponse. Un WebSocket, c’est une ligne qui reste ouverte, et le serveur peut parler en premier : un nouveau message de chat, un prix qui a changé, un build terminé. Ouvrir la ligne tient en une ligne de code. La garder en vie dans un train, sur un portable qui se met en veille ou pendant un déploiement du serveur, voilà le vrai travail.
Cette page construit un socket typé qui se reconnecte tout seul et s’expose sous forme de signals : l’état de la connexion, les messages, une file d’envoi pour ce que vous envoyez hors ligne, et une reconnexion qui ne renverse pas le serveur dès qu’il revient.
Le minimum : webSocket() de RxJS
RxJS fournit un subject pour les WebSockets : subscribe() ouvre la connexion, next() envoie, unsubscribe() la ferme. Par défaut, les messages sont en JSON dans les deux sens (serializer et deserializer changent cela).
import { webSocket } from 'rxjs/webSocket';
interface ChatMessage {
type: 'chat';
user: string;
text: string;
}
const socket = webSocket<ChatMessage>('wss://api.example.com/live');
// subscribe() ouvre la connexion ; chaque message arrive déjà parsé (JSON.parse)
const sub = socket.subscribe({
next: (msg) => console.log(msg.user, msg.text),
error: (err) => console.error('connexion perdue', err), // le flux SE TERMINE ici
complete: () => console.log('fermée proprement par le serveur'), // ...ou ici
});
socket.next({ type: 'chat', user: 'ana', text: 'hi' }); // envoyé comme JSON.stringify(...)
sub.unsubscribe(); // ferme la connexionDeux détails comptent pour la suite. Le flux se termine quand la connexion tombe : avec error si c’était anormal, avec complete si le serveur l’a fermée proprement. Et ce que vous envoyez avant l’ouverture est mis en tampon puis envoyé à la connexion.
Une connexion qui revient : backoff avec jitter
Quand la ligne tombe, réessayer immédiatement en boucle est la pire option. Si le serveur vient de redémarrer, tous les clients le frappent à la même seconde — le thundering herd — et il retombe. La recette habituelle : attendre plus longtemps après chaque échec (backoff exponentiel), plafonner l’attente et ajouter du hasard (jitter) pour que les clients s’étalent.
/**
* « Full jitter » : une attente aléatoire entre 0 et min(cap, base · 2^attempt).
* tentative 1 → 0–1 s, 2 → 0–2 s, 3 → 0–4 s ... jamais plus de 10 s.
*/
export function fullJitter(attempt: number, base = 500, cap = 10_000): number {
return Math.random() * Math.min(cap, base * 2 ** attempt);
}tentative échouéereconnecté
- pic après redémarrage
- 200
- tentatives au total
- 2200
- reconnectés
- 200 / 200
- dernier retour à
- 10.2 s
Fixe 1 s martèle le serveur tant qu’il est tombé. Exponentiel sans jitter envoie moins de requêtes, mais par vagues synchronisées : ceux qui ont échoué au même moment réessaient au même moment. Full jitter étale les retours sur plusieurs secondes, le serveur reçoit un filet au lieu d’un mur ; le prix, c’est que les clients les moins chanceux reviennent quelques secondes plus tard. Le plafond garde cette attente raisonnable. Pour un socket, c’est presque toujours le bon compromis.
retry() ne suffit pas : les fermetures propres
retry() ne réagit qu’aux erreurs. Un serveur qui redémarre poliment ferme les connexions avec le code 1001 ou 1012 (« service restart ») et wasClean: true — et le flux se termine, sans erreur. Sans repeat(), votre application reste déconnectée précisément pendant les déploiements. Remettez le compteur de tentatives à zéro dans openObserver, quand la ligne est vraiment ouverte, pas au premier message.
import { repeat, retry, timer } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
import { fullJitter } from './backoff';
import type { ServerMessage } from './live-socket';
let attempt = 0;
const wait = () => timer(fullJitter(++attempt));
const socket = webSocket<ServerMessage>({
url: 'wss://api.example.com/live',
openObserver: { next: () => (attempt = 0) }, // remise à zéro quand la ligne est vraiment ouverte
});
socket
.pipe(
retry({ delay: wait }), // erreur réseau, fermeture anormale (1006)
repeat({ delay: wait }), // fermeture PROPRE (1001, 1012 « service restart ») : le flux se termine !
)
.subscribe((msg) => console.log(msg));WebSocketSubject garde ce que vous envoyez avant l’ouverture du socket, mais remplace ce tampon à chaque tentative échouée (_resetState() dans le code source de RxJS). Les messages envoyés pendant la coupure sont perdus dès qu’une seule tentative de reconnexion échoue. S’ils comptent, gardez votre propre file d’envoi et videz-la dans openObserver.Le socket comme service avec des signals
Les composants ne devraient rien savoir du backoff ni du JSON. Un service garde une seule connexion pour toute l’application, expose l’état et les messages sous forme de signals et offre un seul send(). Tant que la ligne est coupée, les messages attendent dans une file bornée et partent dans l’ordre après la reconnexion.
import { DestroyRef, Service, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval, repeat, retry, timeout, timer } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
import { fullJitter } from './backoff';
export type ServerMessage =
| { type: 'chat'; user: string; text: string }
| { type: 'presence'; online: number }
| { type: 'pong' };
export type ClientMessage = { type: 'chat'; text: string } | { type: 'ping' };
export type SocketStatus = 'idle' | 'connecting' | 'open' | 'reconnecting';
const PING_EVERY = 15_000;
const DEAD_AFTER = 20_000; // aucun message pendant 20 s = connexion morte
/** Une seule connexion pour toute l’application, exposée sous forme de signals. */
@Service()
export class LiveSocket {
private readonly destroyRef = inject(DestroyRef);
readonly status = signal<SocketStatus>('idle');
readonly online = signal(0);
readonly messages = signal<readonly ServerMessage[]>([]);
/** Ce qui a été envoyé pendant la coupure ; part, dans l’ordre, au prochain « open ». */
readonly outbox = signal<readonly ClientMessage[]>([]);
private attempt = 0;
// RxJS utilise un seul type pour les deux sens
private readonly socket = webSocket<ServerMessage | ClientMessage>({
url: 'wss://api.example.com/live',
openObserver: { next: () => this.opened() },
closeObserver: { next: () => this.status.set('reconnecting') },
});
connect(): void {
if (this.status() !== 'idle') return;
this.status.set('connecting');
const wait = () => timer(fullJitter(++this.attempt));
this.socket
.pipe(
timeout({ each: DEAD_AFTER }), // heartbeat : silence → erreur → retry
retry({ delay: wait }),
repeat({ delay: wait }),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((msg) => this.receive(msg as ServerMessage));
interval(PING_EVERY)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
if (this.status() === 'open') this.socket.next({ type: 'ping' });
});
}
send(msg: ClientMessage): void {
if (this.status() === 'open') this.socket.next(msg);
else this.outbox.update((list) => [...list, msg].slice(-100)); // borné !
}
private opened(): void {
this.status.set('open');
this.attempt = 0;
const pending = this.outbox();
this.outbox.set([]);
pending.forEach((msg) => this.socket.next(msg));
}
private receive(msg: ServerMessage): void {
if (msg.type === 'presence') this.online.set(msg.online);
else if (msg.type === 'chat') this.messages.update((list) => [...list, msg].slice(-200));
}
}- Les événements de la connexion apparaissent ici.
La démo exécute le code ci-dessus avec des intervalles plus courts. Seul WebSocketCtor est remplacé : au lieu d’un vrai WebSocket, il reçoit un serveur en mémoire qui peut redémarrer, perdre le réseau ou figer une connexion.
Heartbeat : une connexion morte qui a l’air vivante
Une connexion peut mourir sans aucun événement de fermeture : on rabat l’écran du portable, un NAT oublie la correspondance, le réseau mobile change de cellule. TCP peut ne rien remarquer pendant des minutes, et readyState dit toujours OPEN. Le remède est un heartbeat : le client envoie un ping à intervalle fixe, et timeout({ each }) traite tout silence plus long que la limite comme une ligne morte. L’erreur va à retry(), qui ouvre une nouvelle connexion.
Ce que vous envoyez sur une ligne morte mais ouverte est perdu sans erreur : send() réussit, les données n’arrivent jamais. Pour les messages qui doivent arriver (une commande, un paiement), donnez un id à chacun, attendez un ack du serveur et renvoyez après la reconnexion ce qui n’a pas été acquitté. Le serveur ignore les doublons par id.
SSR, authentification, onglets et resync
- SSR et prerendering. Le code tourne aussi sur le serveur, où aucun socket ne vaut la peine d’être ouvert. Connectez-vous dans
afterNextRender(ou derrièreisPlatformBrowser) et laissez le rendu serveur afficher un état vide. Voir code compatible serveur. - Authentification. Le navigateur ne vous laisse pas mettre d’en-tête
Authorizationsur un WebSocket. Options : un cookie de session (même site), un ticket court à usage unique obtenu en HTTP et placé dans l’URL, ou un premier messageauth. Pas de token à longue durée de vie dans l’URL — les URL finissent dans les logs du serveur. Les tokens et le refresh sont traités dans Authentification : tokens et refresh. - Plusieurs onglets. Chaque onglet ouvre sa propre connexion. Si c’est un problème (limites de connexions, notifications en double), un onglet peut posséder le socket et transmettre les messages aux autres via
BroadcastChannelou unSharedWorker. - Resync après reconnexion. Pendant votre absence, le serveur a envoyé des choses que vous n’avez jamais reçues. Après chaque
open, demandez ce que vous avez manqué (since=lastEventId) ou rechargez l’état en HTTP. Les modifications faites entre-temps sont le sujet des mises à jour optimistes.
import { HttpClient } from '@angular/common/http';
import { defer, retry, switchMap, timer } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
import { fullJitter } from './backoff';
import type { ServerMessage } from './live-socket';
// Le navigateur ne peut pas mettre d’en-tête Authorization sur un WebSocket.
// 1) un ticket court via HTTP (votre intercepteur d’auth ajoute le token comme d’habitude)
// 2) le ticket est à usage unique et vit ~30 s, il peut donc aller dans l’URL
// 3) defer() → chaque reconnexion demande un NOUVEAU ticket
export function authenticatedSocket(http: HttpClient) {
return defer(() => http.post<{ ticket: string }>('/api/ws-ticket', {})).pipe(
switchMap(({ ticket }) => webSocket<ServerMessage>(`wss://api.example.com/live?ticket=${ticket}`)),
retry({ delay: (_, n) => timer(fullJitter(n)) }),
);
}WebSocket ou Server-Sent Events ?
Si seul le serveur parle — notifications, un fil d’actualité, la progression d’une tâche, du texte envoyé token par token —, les Server-Sent Events sont plus simples : du HTTP ordinaire, une reconnexion intégrée au navigateur et l’id du dernier événement renvoyé automatiquement.
| WebSocket | Server-Sent Events | |
|---|---|---|
| Sens | dans les deux sens | serveur → navigateur |
| Protocole | le sien (ws:// / wss://, après un upgrade HTTP) | HTTP ordinaire (text/event-stream) |
| Reconnexion | à écrire vous-même | intégrée, avec Last-Event-ID |
| Données | texte et binaire | texte uniquement |
| Usage typique | chat, édition collaborative, jeux, trading dans les deux sens | notifications, flux en direct, progression de tâches, streaming de texte IA |
import { Observable } from 'rxjs';
/** Server-Sent Events : serveur → navigateur uniquement, HTTP simple, reconnexion intégrée. */
export function serverEvents<T>(url: string): Observable<T> {
return new Observable<T>((subscriber) => {
const source = new EventSource(url, { withCredentials: true });
source.onmessage = (e) => subscriber.next(JSON.parse(e.data) as T);
source.onerror = () => {
// le navigateur se reconnecte seul (et envoie Last-Event-ID) ; seul CLOSED est définitif
if (source.readyState === EventSource.CLOSED) subscriber.error(new Error('SSE closed'));
};
return () => source.close();
});
}Quand OUI, quand NON
- Les deux côtés parlent souvent : chat, présence, édition collaborative.
- La latence compte, et les messages sont petits et fréquents.
- Il vous faut des données binaires ou votre propre protocole sur la connexion.
- Seul le serveur parle : les Server-Sent Events sont plus simples. Des requêtes de temps en temps : un simple HttpClient.
- Des données qui changent toutes les quelques minutes : un polling toutes les 30–60 s coûte moins cher et s’exploite bien plus simplement.
- Des modifications qui doivent marcher hors ligne et se synchroniser plus tard : c’est un problème offline-first, pas un problème de socket.
Pièges fréquents
- Un socket par composant. Cinq widgets, cinq connexions vers le même serveur. Gardez la connexion dans un service root.
- Retry sans jitter ou sans plafond. Des milliers de clients reviennent par vagues synchronisées, ou un client attend 17 minutes après une longue panne.
- Seulement
retry(), sansrepeat(). Les fermetures propres (à chaque déploiement) terminent le flux pour de bon. - Compter sur le tampon intégré. Les messages envoyés hors ligne disparaissent après la première tentative échouée. Utilisez votre propre file d’envoi, bornée.
- Pas de heartbeat. L’état dit « connecté », mais rien n’est arrivé depuis dix minutes.
- Se connecter pendant le SSR, ou un long JWT dans l’URL. Le premier ouvre des sockets depuis le serveur ; le second laisse des tokens dans les logs.
Aide-mémoire
webSocket<T>(url | config) subscribe = connexion · next() = envoi · unsubscribe = fermeture
avant le premier « open » next() est mis en tampon et envoyé à la connexion
...mais après un échec ce tampon est jeté → gardez votre propre file d’envoi
erreur réseau / code 1006 retry({ delay })
fermeture propre (1000/1001/1012) repeat({ delay }) le flux SE TERMINE, sans erreur
backoff random(0, min(cap, base · 2^n)) « full jitter »
remettre le backoff à zéro dans openObserver, pas au premier message
morte mais pas fermée ping toutes les 15 s + timeout({ each: 20_000 })
SSR / prerender connexion dans afterNextRender, jamais dans un constructeur
auth cookie · ticket court à usage unique · premier message « auth »
après reconnexion resync : demander ce qu’on a manqué (since=lastEventId)
messages qui doivent arriver id + ack du serveur, renvoyer ce qui n’est pas acquitté
un seul sens ? Server-Sent Events (EventSource) : la reconnexion est intégrée