+1 (726) 227-3745

Real-Time MEAN Without WebSockets: MongoDB 8 Change Streams, Express 5 SSE and Angular 22 Signals

Every MEAN project eventually gets the same request: "can this screen update itself?" A shared dashboard, an order queue, a chat pane, a job-progress bar. The instinct is to reach for Socket.IO and a Redis adapter. Often that is more machinery than the problem needs.

This tutorial builds a real-time feature end to end with parts you already have in a 2026 MEAN stack: MongoDB 8 change streams as the event source, Express 5 Server-Sent Events (SSE) as the transport, and Angular 22 signals as the rendering model. No extra broker, no second protocol, and no polling loop hammering your primary.

We use a live "order queue" as the example: operators watch a list of orders, and any status change made by anyone appears on every screen within a second.

Versions used: Node.js 24 LTS, Express 5.1, Mongoose 8, MongoDB 8.0 (replica set or Atlas — change streams require a replica set; a single mongod without one will not work), Angular 22.

Why SSE and not WebSockets

SSE is a plain HTTP response with Content-Type: text/event-stream that never ends. It costs one connection per client, and in exchange you get things you would otherwise build yourself:

  • One direction only. Server pushes, client reads. Real-time dashboards are almost always one-directional; writes still go through your normal REST endpoints, with the normal validation, auth, and rate limiting.
  • Automatic reconnect. Browsers retry a dropped EventSource on their own, and replay the Last-Event-ID header so you can resume where the client left off.
  • It is just HTTP. Bearer-token auth, gzip, HTTP/2 multiplexing, your existing reverse proxy, your existing observability. No Upgrade handshake to teach your load balancer.

Reach for WebSockets when the client needs to push at high frequency: collaborative editing, multiplayer cursors, voice signalling. For "the server tells the browser something changed," SSE wins on operational simplicity — and it is far easier to hand to a client team after we leave.

Part 1: the change stream

A change stream is a tailable cursor over the replica set oplog. Mongoose exposes it on any model with Model.watch().

// api/src/orderEvents.js
import { EventEmitter } from 'node:events';
import { Order } from './models.js';

export const orderEvents = new EventEmitter();
orderEvents.setMaxListeners(0); // one listener per connected browser

let stream;

export function startOrderStream(resumeAfter) {
  stream = Order.watch(
    [
      { $match: { operationType: { $in: ['insert', 'update', 'replace', 'delete'] } } },
    ],
    {
      fullDocument: 'updateLookup',
      resumeAfter,
      maxAwaitTimeMS: 10_000,
    },
  );

  stream.on('change', (change) => {
    orderEvents.emit('change', {
      id: change._id,                       // resume token
      type: change.operationType,
      orderId: String(change.documentKey._id),
      order: change.fullDocument ?? null,
    });
  });

  stream.on('error', (err) => {
    console.error('[orderStream] error', err);
    // Resumable errors are retried by the driver. Anything that reaches
    // here has already exhausted that, so rebuild from the last token.
    const token = stream.resumeToken;
    stream.close().catch(() => {});
    setTimeout(() => startOrderStream(token), 1_000);
  });

  return stream;
}

Three details that matter in production:

  1. fullDocument: 'updateLookup' makes MongoDB fetch the current document for updates. Without it you get only the changed fields, and clients that joined late cannot render a row. The lookup is a second read and it returns the document as it is now, not as it was at the moment of the update — fine for a dashboard, wrong for an audit log.
  2. Open exactly one change stream per process, not one per connected browser. Each stream is a cursor on the primary; a hundred idle dashboards should not become a hundred cursors. Fan out in-process with an EventEmitter.
  3. Filter in the aggregation pipeline, not in JavaScript. $match on operationType (and on fullDocument.tenantId if you are multi-tenant) means the server never sends you events you would discard.

If you need fullDocument: 'whenAvailable' semantics or before-images, enable changeStreamPreAndPostImages on the collection — it costs storage, so turn it on only where it is used.

Part 2: the SSE endpoint in Express 5

// api/src/sse.js
import { orderEvents } from './orderEvents.js';
import { requireAuth } from './auth.js';
import { Order } from './models.js';

export function mountOrderStream(app) {
  app.get('/api/orders/stream', requireAuth, async (req, res) => {
    res.set({
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
      'X-Accel-Buffering': 'no', // stop nginx from buffering the stream
    });
    res.flushHeaders();

    // 1. Snapshot first, so the client has state before deltas arrive.
    const snapshot = await Order.find({ status: { $ne: 'archived' } })
      .sort({ createdAt: -1 })
      .limit(100)
      .lean();
    send(res, 'snapshot', { orders: snapshot });

    // 2. Deltas.
    const onChange = (event) => send(res, 'change', event, event.id?._data);
    orderEvents.on('change', onChange);

    // 3. Heartbeat: a comment line every 20s keeps proxies and
    //    load balancers from reaping an idle connection.
    const beat = setInterval(() => res.write(': ping\n\n'), 20_000);

    req.on('close', () => {
      clearInterval(beat);
      orderEvents.off('change', onChange);
    });
  });
}

function send(res, event, data, id) {
  if (id) res.write(`id: ${id}\n`);
  res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
  // Never forget the blank line; without it the browser buffers forever.
}

The snapshot-then-deltas order is the part teams get wrong. If you stream deltas without a snapshot, a browser that connects at 10:31 has no idea what happened before 10:31. If you fetch the snapshot with a separate REST call, you get a race window where a change lands between the query and the subscription. Subscribing to the emitter after the query but inside the same handler narrows that window to microseconds; if you need it closed completely, buffer emitted events during the query and replay them.

Do not forget backpressure. res.write() returns false when the socket buffer is full — usually a laptop that went to sleep. A dashboard that drops events is fine; just do not let the buffer grow without bound:

const ok = res.write(payload);
if (!ok) {
  // Slow consumer: drop it and let EventSource reconnect with Last-Event-ID.
  res.end();
}

Part 3: resuming with Last-Event-ID

When the browser reconnects, it sends the last id: it saw. Turn that back into a resume token and replay:

const lastId = req.get('last-event-id');
if (lastId) {
  const cursor = Order.watch([], { resumeAfter: { _data: lastId }, fullDocument: 'updateLookup' });
  // Drain everything the client missed, then fall through to the live emitter.
}

Resume tokens are only valid while the events are still in the oplog. Check your window with db.getReplicationInfo(); if the oplog holds 24 hours and a client was offline for two days, watch() throws ChangeStreamHistoryLost (error code 286). Catch it and send a fresh snapshot instead — the client should handle a snapshot event at any time, not just the first one.

Part 4: the Angular 22 client

A small service wraps EventSource and exposes state as signals. Note takeUntilDestroyed and the DestroyRef, and that everything runs outside change detection until a signal is set — with zoneless Angular 22 that is exactly what you want.

// src/app/orders/order-stream.service.ts
import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';

export interface Order { _id: string; ref: string; status: string; total: number; }

@Injectable({ providedIn: 'root' })
export class OrderStreamService {
  private readonly destroyRef = inject(DestroyRef);
  private readonly byId = signal(new Map<string, Order>());

  readonly connected = signal(false);
  readonly orders = computed(() =>
    [...this.byId().values()].sort((a, b) => a.ref.localeCompare(b.ref)),
  );
  readonly pending = computed(() => this.orders().filter((o) => o.status === 'pending').length);

  connect(): void {
    const source = new EventSource('/api/orders/stream', { withCredentials: true });

    source.addEventListener('open', () => this.connected.set(true));
    source.addEventListener('error', () => this.connected.set(false)); // browser retries

    source.addEventListener('snapshot', (e) => {
      const { orders } = JSON.parse((e as MessageEvent).data);
      this.byId.set(new Map(orders.map((o: Order) => [o._id, o])));
    });

    source.addEventListener('change', (e) => {
      const { type, orderId, order } = JSON.parse((e as MessageEvent).data);
      this.byId.update((map) => {
        const next = new Map(map);
        if (type === 'delete') next.delete(orderId);
        else if (order) next.set(orderId, order);
        return next;
      });
    });

    this.destroyRef.onDestroy(() => source.close());
  }
}

The component is then boring, which is the point:

@Component({
  selector: 'app-order-queue',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (!stream.connected()) { <p class="warn">Reconnecting…</p> }
    <p>{{ stream.pending() }} pending</p>
    <ul>
      @for (order of stream.orders(); track order._id) {
        <li>{{ order.ref }} — {{ order.status }}</li>
      }
    </ul>
  `,
})
export class OrderQueueComponent {
  protected readonly stream = inject(OrderStreamService);
  constructor() { this.stream.connect(); }
}

Because the map is a signal, only the rows whose identity changed re-render. No NgZone, no manual markForCheck(), no trackBy function — the track expression in @for does the work.

Two client-side gotchas

  • The six-connection limit. Over HTTP/1.1 a browser allows six connections per origin, and an open EventSource occupies one per tab. Three tabs on your dashboard plus a slow upload and the app is wedged. Serve over HTTP/2, where streams are multiplexed, or share one connection across tabs with a BroadcastChannel.
  • Auth headers. The native EventSource cannot set an Authorization header. Either use a cookie session for the stream route, or use fetch() with a ReadableStream reader (which does support headers) and parse the event frames yourself.

Where this breaks, and what to do about it

Multiple API instances. Each Node process opens its own change stream, so every instance sees every event and pushes it to its own clients. That works and needs no broker — but N instances means N cursors on the primary. Up to a handful of instances this is unremarkable; beyond that, run one "publisher" process that owns the stream and fans out over Redis pub/sub, and keep the SSE endpoints stateless.

Not everything is a document write. Change streams see database changes. If a state transition happens only in application memory, or in a third-party webhook you have not persisted yet, it will never appear. Write it down first; the stream is a consequence of your data model, not a replacement for it.

Per-user filtering. Never send an event to a client and filter it in the browser — that is a data leak with extra steps. Filter server-side per subscriber against the same authorization rules your REST endpoints use, and prefer a $match on the tenant field in the pipeline so unrelated events never leave the database.

Observability. Track connected clients, events emitted per second, and change stream restarts. A change stream that silently dies makes a dashboard look calm rather than broken, which is the worst possible failure mode. If you already have OpenTelemetry wired in, emit a span per fan-out and a gauge for open connections.

Checklist before you ship

  1. MongoDB is a replica set (Atlas always is) and the oplog window is long enough for your worst realistic client outage.
  2. One change stream per process, fan-out in memory, resume token persisted on error.
  3. SSE responses set X-Accel-Buffering: no and send a heartbeat; your proxy's read timeout is longer than the heartbeat interval.
  4. Snapshot sent before deltas, and the client can accept a fresh snapshot at any time.
  5. Served over HTTP/2, or connections shared across tabs.
  6. Authorization applied per subscriber, in the pipeline where possible.
  7. Slow consumers are disconnected rather than buffered.

Where this fits

Live dashboards are one of the highest-value things you can add to an existing MEAN app, and one of the easiest to build badly — the failure modes are all in reconnection, authorization and fan-out, none of which show up on a developer laptop. We build and review real-time features as part of our MEAN Stack app development and performance tuning work. If your dashboard is currently a setInterval poll and it is starting to hurt your database, get in touch.