HTTP is a question followed by an answer. A WebSocket is a line that stays open, and the server can speak first: a new chat message, a price that changed, a build that finished. Opening the line takes one line of code. Keeping it alive on a train, on a laptop that goes to sleep or while the server redeploys is the real work.
This page builds a typed socket that reconnects on its own and is exposed as signals: connection status, messages, an outbox for what you send while offline, and a reconnect that doesn't knock the server over again the moment it comes back.
The minimum: webSocket() from RxJS
RxJS ships a subject for WebSockets: subscribe() opens the connection, next() sends, unsubscribe() closes it. Messages are JSON in both directions by default (serializer and deserializer change that).
import { webSocket } from 'rxjs/webSocket';
interface ChatMessage {
type: 'chat';
user: string;
text: string;
}
const socket = webSocket<ChatMessage>('wss://api.example.com/live');
// subscribe() opens the connection; every message arrives already parsed (JSON.parse)
const sub = socket.subscribe({
next: (msg) => console.log(msg.user, msg.text),
error: (err) => console.error('connection lost', err), // the stream ENDS here
complete: () => console.log('closed cleanly by the server'), // ...or here
});
socket.next({ type: 'chat', user: 'ana', text: 'hi' }); // sent as JSON.stringify(...)
sub.unsubscribe(); // closes the connectionTwo details matter later. The stream ends when the connection drops: with error if it was abnormal, with complete if the server closed it cleanly. And what you send before the connection is open is buffered and sent on connect.
A connection that comes back: backoff with jitter
When the line drops, retrying immediately in a loop is the worst option. If the server restarted, every client hits it in the same second — the thundering herd — and the server falls over again. The usual recipe: wait longer after each failure (exponential backoff), cap the wait, and add randomness (jitter) so clients spread out.
/**
* "Full jitter": a random wait between 0 and min(cap, base · 2^attempt).
* attempt 1 → 0–1 s, 2 → 0–2 s, 3 → 0–4 s ... never more than 10 s.
*/
export function fullJitter(attempt: number, base = 500, cap = 10_000): number {
return Math.random() * Math.min(cap, base * 2 ** attempt);
}failed attemptreconnected
- peak after restart
- 200
- total attempts
- 2200
- back online
- 200 / 200
- last one back at
- 10.2 s
Fixed 1 s hammers the server for as long as it's down. Exponential without jitter sends fewer requests, but in synchronised waves: every client that failed at the same time retries at the same time. Full jitter spreads the returns over several seconds, so the server gets a trickle instead of a wall; the price is that the unluckiest clients come back a few seconds later. The cap keeps that wait reasonable. For a socket, it's almost always the right trade.
retry() is not enough: clean closes
retry() only reacts to errors. A server that restarts politely closes connections with code 1001 or 1012 (“service restart”) and wasClean: true — and then the stream completes, it doesn't error. Without repeat() your app stays disconnected exactly during deploys. Reset the attempt counter in openObserver, when the line is really open, not on the first 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) }, // reset when the line is really open
});
socket
.pipe(
retry({ delay: wait }), // network error, abnormal close (1006)
repeat({ delay: wait }), // CLEAN close (1001, 1012 "service restart"): the stream completes!
)
.subscribe((msg) => console.log(msg));WebSocketSubject keeps what you send before the socket opens, but replaces that buffer on every failed attempt (_resetState() in the RxJS source). Messages sent while the network is down are lost as soon as one reconnect attempt fails. If they matter, keep your own outbox and empty it in openObserver.The socket as a service with signals
Components shouldn't know about backoff or JSON. A service keeps one connection for the whole application, exposes status and messages as signals and offers a single send(). While the line is down, messages wait in a bounded outbox and leave in order after reconnect.
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; // no message at all for 20 s = a dead connection
/** One connection for the whole app, exposed as signals. */
@Service()
export class LiveSocket {
private readonly destroyRef = inject(DestroyRef);
readonly status = signal<SocketStatus>('idle');
readonly online = signal(0);
readonly messages = signal<readonly ServerMessage[]>([]);
/** What was sent while the line was down; it leaves, in order, on the next "open". */
readonly outbox = signal<readonly ClientMessage[]>([]);
private attempt = 0;
// RxJS uses a single type for both directions
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 → error → 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)); // bounded!
}
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));
}
}- Connection events appear here.
The demo runs the code above with shorter intervals. Only WebSocketCtor is replaced: instead of a real WebSocket, it gets an in-memory server that can restart, lose the network or freeze a connection.
Heartbeat: a dead connection that looks alive
A connection can die without any close event: a laptop lid closes, a NAT forgets the mapping, a mobile network switches cells. TCP may not notice for minutes, and readyState still says OPEN. The cure is a heartbeat: the client sends a ping at a fixed interval, and timeout({ each }) treats any silence longer than the limit as a dead line. The error goes to retry(), which opens a fresh connection.
What you send on a dead-but-open line is lost without an error: send() succeeds, the data never arrives. For messages that must arrive (an order, a payment), give each one an id, wait for an ack from the server and resend what wasn't acknowledged after reconnect. The server ignores duplicates by id.
SSR, authentication, tabs and resync
- SSR and prerendering. The code also runs on the server, where there is no socket worth opening. Connect in
afterNextRender(or behindisPlatformBrowser) and let the server render an empty state. See server-compatible code. - Authentication. The browser doesn't let you set an
Authorizationheader on a WebSocket. Options: a session cookie (same site), a short single-use ticket fetched over HTTP and put in the URL, or a firstauthmessage. Don't put a long-lived token in the URL — URLs end up in server logs. Tokens and refresh are covered in Authentication: tokens & refresh. - Multiple tabs. Each tab opens its own connection. If that's a problem (connection limits, duplicate notifications), one tab can own the socket and pass messages to the others through
BroadcastChannelor aSharedWorker. - Resync after reconnect. While you were away, the server sent things you never received. After every
open, ask for what you missed (since=lastEventId) or reload the state over HTTP. Changes you made in the meantime are the topic of optimistic updates.
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';
// The browser cannot set an Authorization header on a WebSocket.
// 1) a short ticket over HTTP (your auth interceptor adds the token as usual)
// 2) the ticket is single-use and lives ~30 s, so it is fine in the URL
// 3) defer() → every reconnect asks for a NEW 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 or Server-Sent Events?
If only the server talks — notifications, a feed, the progress of a job, text streamed token by token — Server-Sent Events are simpler: plain HTTP, reconnect built into the browser, and the last event id sent back automatically.
| WebSocket | Server-Sent Events | |
|---|---|---|
| Direction | both ways | server → browser |
| Protocol | its own (ws:// / wss://, after an HTTP upgrade) | plain HTTP (text/event-stream) |
| Reconnect | yours to write | built in, with Last-Event-ID |
| Data | text and binary | text only |
| Typical use | chat, collaborative editing, games, two-way trading | notifications, live feeds, job progress, AI text streaming |
import { Observable } from 'rxjs';
/** Server-Sent Events: server → browser only, plain HTTP, reconnect built in. */
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 = () => {
// the browser reconnects by itself (and sends Last-Event-ID); only CLOSED is final
if (source.readyState === EventSource.CLOSED) subscriber.error(new Error('SSE closed'));
};
return () => source.close();
});
}When YES, when NO
- Both sides talk often: chat, presence, collaborative editing.
- Latency matters and messages are small and frequent.
- You need binary data or your own protocol over the connection.
- Only the server talks: Server-Sent Events are simpler. Requests now and then: plain HttpClient.
- Data that changes every few minutes: polling every 30–60 s is cheaper and much simpler to run.
- Updates that must work offline and sync later: that's an offline-first problem, not a socket problem.
Common pitfalls
- One socket per component. Five widgets, five connections to the same server. Keep the connection in a root service.
- Retry without jitter or without a cap. Thousands of clients come back in synchronised waves, or a client waits 17 minutes after a long outage.
- Only
retry(), norepeat(). Clean closes (every deploy) end the stream for good. - Counting on the built-in buffer. Messages sent offline disappear after the first failed attempt. Use your own bounded outbox.
- No heartbeat. The status says “connected”, but nothing has arrived for ten minutes.
- Connecting during SSR, or a long JWT in the URL. The first opens sockets from the server; the second leaves tokens in logs.
Cheat sheet
webSocket<T>(url | config) subscribe = connect · next() = send · unsubscribe = close
before the first "open" next() is buffered and sent on connect
...but after a failed attempt that buffer is thrown away → keep your own outbox
network error / code 1006 retry({ delay })
clean close (1000/1001/1012) repeat({ delay }) the stream COMPLETES, it does not error
backoff random(0, min(cap, base · 2^n)) "full jitter"
reset the backoff in openObserver, not on the first message
dead but not closed ping every 15 s + timeout({ each: 20_000 })
SSR / prerender connect in afterNextRender, never in a constructor
auth cookie · short single-use ticket · first "auth" message
after reconnect resync: ask for what you missed (since=lastEventId)
must-arrive messages id + ack from the server, resend what was not acked
one direction only? Server-Sent Events (EventSource): reconnect is built in