Realtime Chat App
A chat app is the perfect project for mastering bidirectional, server-pushed data. Unlike a REST poll-and-refresh loop, a WebSocket holds a single persistent connection that streams messages the instant they arrive. In this build you will wrap that socket in an RxJS stream, fold incoming events into a signal-backed message list, and render the conversation reactively — all without leaking subscriptions or fighting change detection. By the end you will have a working room where typing in one tab appears live in another.
Everything here is modern Angular: standalone components, signals, inject(), the new control-flow syntax, and RxJS as the transport layer. For the socket itself we use Angular’s webSocket helper from rxjs/webSocket, which gives a Subject you can both subscribe to and push into.
How the pieces fit
A WebSocket is full-duplex: the same connection carries messages in both directions. RxJS models this beautifully — the socket is a Subject, so you subscribe to read inbound frames and call next() to write outbound ones. We keep all of that in a service, expose a clean message stream, and let the component translate that stream into reactive UI state.
| Concern | Owner | Tool |
|---|---|---|
| Connection lifecycle | ChatService | webSocket() Subject |
| Reconnect on drop | ChatService | retryWhen / retry |
| Message list state | Component | signal<Message[]> |
| Rendering | Template | @for / @if |
| Sending | Component → service | Subject.next() |
Modeling the message
Type the wire format so both ends agree on the shape. Each message carries an id, the author, the text, and a timestamp.
// src/app/chat.model.ts
export interface ChatMessage {
id: string;
user: string;
text: string;
sentAt: number; // epoch millis
}
export type Outbound = Pick<ChatMessage, 'user' | 'text'>;
The socket service
The service opens the connection lazily, shares one socket across subscribers, and reconnects automatically if the server drops. webSocket<T> returns a WebSocketSubject typed to your message shape, so JSON serialization happens for free in both directions.
// src/app/chat.service.ts
import { inject, Injectable, NgZone } from '@angular/core';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { Observable, retry, timer } from 'rxjs';
import { ChatMessage, Outbound } from './chat.model';
@Injectable({ providedIn: 'root' })
export class ChatService {
private socket?: WebSocketSubject<ChatMessage>;
private readonly url = 'wss://chat.example.com/room/general';
/** Lazily creates a single shared socket and reconnects on drop. */
private connect(): WebSocketSubject<ChatMessage> {
if (!this.socket || this.socket.closed) {
this.socket = webSocket<ChatMessage>({
url: this.url,
openObserver: { next: () => console.log('[chat] connected') },
closeObserver: { next: () => console.log('[chat] disconnected') },
});
}
return this.socket;
}
/** Stream of inbound messages, with exponential-ish backoff on errors. */
messages(): Observable<ChatMessage> {
return this.connect().pipe(
retry({ delay: (_, count) => timer(Math.min(count * 1000, 5000)) }),
);
}
send(message: Outbound): void {
this.connect().next({
id: crypto.randomUUID(),
sentAt: Date.now(),
...message,
});
}
disconnect(): void {
this.socket?.complete();
}
}
The
retryoperator re-subscribes after thedelaytimer fires. BecausewebSocketopens the connection on subscribe, re-subscribing transparently reopens a dropped socket — capped here at a 5-second backoff so a flaky network does not hammer the server.
Reactive state in the component
The component subscribes to the message stream and accumulates frames into a signal. Using takeUntilDestroyed() ties the subscription to the component lifetime, so it tears down automatically — no manual ngOnDestroy.
// src/app/chat-room.component.ts
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ChatService } from './chat.service';
import { ChatMessage } from './chat.model';
@Component({
selector: 'app-chat-room',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './chat-room.component.html',
})
export class ChatRoomComponent {
private chat = inject(ChatService);
readonly me = 'Ada';
messages = signal<ChatMessage[]>([]);
draft = new FormControl('', { nonNullable: true });
constructor() {
this.chat
.messages()
.pipe(takeUntilDestroyed())
.subscribe((msg) =>
this.messages.update((list) => [...list, msg]),
);
}
send(): void {
const text = this.draft.value.trim();
if (!text) return;
this.chat.send({ user: this.me, text });
this.draft.reset();
}
}
Note that we update the signal immutably with a new array. Signals only notify when the reference changes, so pushing into the existing array in place would not trigger a re-render.
The chat template
The template renders the running list with @for, keyed by message id so Angular can track rows efficiently. An @empty block covers the initial state, and outgoing messages get a different style.
<!-- chat-room.component.html -->
<ul class="thread">
@for (msg of messages(); track msg.id) {
<li [class.mine]="msg.user === me">
<strong>{{ msg.user }}</strong>
<span>{{ msg.text }}</span>
<time>{{ msg.sentAt | date: 'shortTime' }}</time>
</li>
} @empty {
<li class="hint">No messages yet — say hello.</li>
}
</ul>
<form (submit)="send(); $event.preventDefault()">
<input [formControl]="draft" placeholder="Type a message…" autocomplete="off" />
<button type="submit">Send</button>
</form>
With two browser tabs open on the same room, a message sent in one appears in the other the moment the server broadcasts it.
Output:
[chat] connected
Ada Hi there! 9:41 AM
Grace Hello Ada 9:41 AM
Ada WebSockets are fun 9:42 AM
Best practices
- Keep all socket lifecycle logic in a service; components should only consume a stream and call
send(), never touchWebSocketdirectly. - Reuse a single shared
WebSocketSubjectacross the app rather than opening a connection per component — multiple subscribers should multiplex one socket. - Always handle reconnection with
retryand a capped backoff; networks drop, and a chat that dies on the first hiccup feels broken. - Update signals immutably (
update((list) => [...list, msg])) so change detection actually fires on each new message. - Bind subscriptions to the component with
takeUntilDestroyed()to guarantee teardown and avoid duplicate listeners after navigation. - Validate and trim user input before sending, and consider an outbound queue so messages typed while disconnected flush on reconnect.
- For large threads, cap the rendered list (e.g. keep the last 200 messages) or virtualize it to keep the DOM light.