Skip to content
Angular projects 5 min read

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.

ConcernOwnerTool
Connection lifecycleChatServicewebSocket() Subject
Reconnect on dropChatServiceretryWhen / retry
Message list stateComponentsignal<Message[]>
RenderingTemplate@for / @if
SendingComponent → serviceSubject.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 retry operator re-subscribes after the delay timer fires. Because webSocket opens 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 touch WebSocket directly.
  • Reuse a single shared WebSocketSubject across the app rather than opening a connection per component — multiple subscribers should multiplex one socket.
  • Always handle reconnection with retry and 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.
Last updated June 14, 2026
Was this helpful?