AngularAtlased. 22.2

Ch. 15Real time & offline65 / 75

WebSockets: real time that survives the network

A typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.

architecture ~0 min read

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.

Why it matters
Production sockets die all the time: Wi-Fi changes networks, proxies close idle connections after a minute, deploys restart servers. A socket without reconnect looks perfect in the demo, then silently stops updating in the user's hands — with no error on screen.

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 connection

Two 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);
}
Live200 clients reconnect after a server restart
server back 0 s 10 s 20 s 30 s 40 s

failed attemptreconnected

peak after restart
200
total attempts
2200
back online
200 / 200
last one back at
10.2 s
Compare the three strategies. Watch the peak: how many clients hit the server in the same half-second right after it comes back.

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));
The buffer you can't rely on
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));
  }
}
LiveA live connection: cut the network, restart the server
not connected0 online
  1. Write a message: the server echoes it to everyone connected.
  1. Connection events appear here.
Cut the network, send two messages, then restore it: they leave in order after reconnect. Then try “Silent drop”: the connection stays open but nothing gets through.

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 behind isPlatformBrowser) and let the server render an empty state. See server-compatible code.
  • Authentication. The browser doesn't let you set an Authorization header on a WebSocket. Options: a session cookie (same site), a short single-use ticket fetched over HTTP and put in the URL, or a first auth message. 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 BroadcastChannel or a SharedWorker.
  • 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.

WebSocketServer-Sent Events
Directionboth waysserver → browser
Protocolits own (ws:// / wss://, after an HTTP upgrade)plain HTTP (text/event-stream)
Reconnectyours to writebuilt in, with Last-Event-ID
Datatext and binarytext only
Typical usechat, collaborative editing, games, two-way tradingnotifications, 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

WebSocket
  • 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.
Something else
  • 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(), no repeat(). 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

Ch. 15 · 0 of 3 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. WebSockets: real time that survives the network
  2. Optimistic updates & rollback
  3. Offline-first: Service Worker & caching
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation