Blogs
Backend Architecture9 min read

Server-Sent Events (SSE): Real-Time Notifications in SRVJ

How SRVJ delivers real-time notifications with Server-Sent Events, BullMQ, Redis Pub/Sub, and PostgreSQL — a persist-then-fan-out pipeline that scales horizontally without sticky sessions.

SSEServer-Sent EventsReal-TimeBullMQRedisPostgreSQLNode.jsSystem Design

Before diving into SSE, here's a quick overview of SRVJ.

SRVJ is a side project I've been building to explore and experiment with different technologies and architectural patterns. Inspired by tools like Miro, the project focuses on real-time collaboration and serves as a playground for learning, validating ideas, and gaining hands-on experience with modern backend technologies.

Through SRVJ, I've been experimenting with technologies such as:

  • CRDTs & Yjs
  • Server-Sent Events (SSE)
  • Socket.IO / WebSockets
  • Docker
  • Kubernetes
  • PostgreSQL & MongoDB
  • Background processing with BullMQ

This post is the first in a series where I'll share some of the technical decisions behind the project, starting with Server-Sent Events (SSE).

What is SSE?

Server-Sent Events (SSE) is an HTTP-based technology that enables servers to push updates to connected clients over a long-lived connection.

Unlike WebSockets, communication is one-way: server → client.

Browsers provide native support through the EventSource API, which makes the client implementation straightforward.

Why SSE?

Not every real-time feature requires bidirectional communication.

In SRVJ, collaborative editing relies on WebSockets because users continuously exchange document updates. Notifications, however, are different: clients only need to receive events generated by the server.

For this specific use case, Server-Sent Events (SSE) turned out to be a great fit.

Notification Architecture

The notification pipeline in SRVJ is intentionally asynchronous.

  • A domain event occurs (board invitation, chat message, etc.).
  • A BullMQ job is created.
  • A worker processes the job.
  • The notification is persisted in PostgreSQL.
  • The worker publishes the event to Redis Pub/Sub.
  • The application instance that owns the user's SSE connection delivers the notification instantly.

This architecture decouples notification generation from delivery and allows the system to scale horizontally.

Opening the SSE Stream

Every authenticated user establishes a long-lived HTTP connection to /stream.

sse.stream.tsts
res.writeHead(200, {
  "Content-Type": "text/event-stream",
  "Cache-Control": "no-cache",
  Connection: "keep-alive",
  "X-Accel-Buffering": "no",
});
res.flushHeaders?.();

Why these headers?

  • Content-Type: text/event-stream — Tells the browser that this endpoint will continuously stream events rather than returning a traditional HTTP response.
  • Cache-Control: no-cache — Prevents intermediaries and browsers from caching streamed events.
  • Connection: keep-alive — Keeps the HTTP connection open for future events.
  • X-Accel-Buffering: no — Disables buffering in Nginx. Without this header, notifications may be delayed because Nginx could buffer responses before sending them to clients.

Managing Active Connections

SRVJ allows the same user to be connected from multiple browser tabs or devices simultaneously.

To support this behavior, active connections are stored using:

sse.connections.tsts
const client: SSEClient = { userId, res };
let connections = clients.get(userId);
if (!connections) {
  connections = new Set<SSEClient>();
  clients.set(userId, connections);
}
connections.add(client);

Internally, the structure looks like:

snippetts
Map<userId, Set<SSEClient>>

Why use this structure?

Using a Map provides constant-time lookup for all active connections belonging to a user.

Using a Set allows:

  • Multiple tabs per user.
  • Multiple devices per user.
  • Efficient connection removal.
  • Prevention of duplicate connections.

As a result, a user receives notifications across every active session.

Immediately Opening the Stream

After the connection is registered, the server immediately writes an empty event:

sse.stream.tsts
res.write(`: connected\n\n`);

Why?

This comment frame forces the connection to become active immediately so the browser fires the onopen event without waiting for the first notification.

Cleaning Up Disconnected Clients

Because SSE connections are long-lived, proper cleanup is essential.

sse.cleanup.tsts
req.on("close", () => {
  connections!.delete(client);
  if (connections!.size === 0) {
    clients.delete(userId);
  }
});

Why?

Without cleanup:

  • Memory usage would continuously grow.
  • Dead connections would remain in memory.
  • The server would attempt to write to closed responses.

Removing stale connections prevents memory leaks.

Redis as the Distribution Layer

SRVJ may run on multiple application instances.

For example:

  • Instance A → User 1 connected
  • Instance B → User 2 connected
  • Instance C → Worker running

A worker should not need to know which instance owns a user's SSE connection.

To solve this problem, Redis Pub/Sub acts as the distribution layer.

First, two Redis clients are created:

redis.tsts
export const redis = createClient({ url });
export const subscriber = redis.duplicate();

Why duplicate the Redis client?

Redis connections operating in Pub/Sub mode cannot be used normally for other commands.

Creating a dedicated subscriber connection isolates Pub/Sub traffic from the rest of the application.

Publishing Notifications

After the worker persists the notification in PostgreSQL, it publishes an event.

notification.worker.tsts
const payload = {
  id: uuidv4(),
  sender: data.sender,
  userId: data.userId,
  type: data.type,
  title: data.title,
  message: data.message,
  createdAt: new Date().toISOString(),
};

await prisma.notification.create({
  data: {
    fromUserId: Number(payload.sender),
    toUserId: Number(payload.userId),
    title: payload.title,
    message: payload.message,
  }
});

await redis.publish(
  "notifications",
  JSON.stringify(payload)
);

Why store the notification before publishing?

Persisting first guarantees durability.

If a user is offline, notifications remain available and can later be retrieved using the REST API.

Publishing after persistence ensures that no delivered notification is lost.

Delivering Notifications to Connected Users

Every application instance subscribes to Redis.

sse.subscriber.tsts
await subscriber.subscribe(
  "notifications",
  (message) => {
    const payload = JSON.parse(message);
    const connections = clients.get(
      String(payload.userId)
    );
    if (!connections || connections.size === 0)
      return;

    const frame =
      `event: notification\n` +
      `data: ${JSON.stringify(payload)}\n\n`;

    for (const client of connections) {
      client.res.write(frame);
    }
  }
);

Why this approach?

  • Each server instance only knows about its local SSE connections.
  • Redis broadcasts the event to every instance.
  • Only the instance holding the user's connection actually sends the event.

This architecture allows horizontal scaling without introducing sticky sessions or centralized connection management.

Background Processing with BullMQ

Notification delivery is executed asynchronously through BullMQ.

notification-flow.txttext
User Action
      ↓
BullMQ Job
      ↓
Worker
      ↓
Database
      ↓
Redis Pub/Sub
      ↓
SSE

Why use BullMQ?

  • Prevents request blocking.
  • Improves API response times.
  • Supports retries.
  • Handles transient failures.
  • Decouples business logic from delivery logic.

A failed notification can be retried without affecting the user's original request.

Hardening for Production

The pipeline above is the version that ships first, and it's deliberately simple. As SRVJ grows past a single instance and starts retrying jobs under load, three refinements matter. None of them change the core idea — they make it correct at scale.

Scaling the Fan-Out: Per-User Channels

The version above publishes every notification to a single global notifications channel, and every instance subscribes to it. That's the simplest thing that works, and at a small number of instances it's completely fine.

But notice what happens as you scale out: every instance receives every notification and then discards the ones it doesn't own. With N instances, roughly (N-1)/N of that fan-out is wasted CPU and network that grows with both notification volume and instance count.

The refinement is per-user channels — notif:user:{id}. Each instance subscribes only to the users currently connected to it, and unsubscribes when the last tab for that user disconnects:

sse.channels.tsts
// on connect (first tab for this user on this instance)
await subscriber.subscribe(`notif:user:${userId}`, handleMessage);

// on disconnect (last tab gone)
await subscriber.unsubscribe(`notif:user:${userId}`);

The publish side targets the user directly instead of broadcasting:

notification.worker.tsts
await redis.publish(`notif:user:${payload.userId}`, JSON.stringify(payload));

Now each instance receives only the messages for users it actually holds.

Tradeoffs. You trade a fixed broadcast cost for subscribe/unsubscribe churn on every connect and disconnect, plus many short-lived channels in Redis. That's a good trade once instance count and notification volume grow; the single global channel is fine while you're small. Pick the per-user model the moment you horizontally scale the app tier in earnest.

Idempotent Worker Writes

BullMQ delivers at-least-once. A worker that crashes after writing to PostgreSQL but before acking the job will see that job again on restart — and a blind create produces a duplicate notification.

The fix is a stable dedup key (the domain eventId, or a deterministic hash of type + sender + recipient + target) plus a unique constraint, so the second delivery becomes a no-op instead of a duplicate:

notification.worker.tsts
await prisma.notification.upsert({
  where: { eventId: payload.eventId },
  update: {},
  create: {
    eventId: payload.eventId,
    fromUserId: Number(payload.sender),
    toUserId: Number(payload.userId),
    title: payload.title,
    message: payload.message,
  },
});

Tradeoff. You need a deterministic key and a unique column — a little schema discipline. And worth being honest: exactly-once across queue → DB → Redis doesn't really exist. Idempotent writes are how you approximate it, and they're non-negotiable the moment the consumer has side effects.

Surviving Reconnections

SSE auto-reconnects, but Redis Pub/Sub has no buffer: anything published while a client was disconnected is simply gone. Two ways to close that gap:

  • Refetch on reconnect — When EventSource fires onopen, the client calls the REST list endpoint (GET /notifications) to reconcile against PostgreSQL. This needs nothing extra and is the pragmatic default for SRVJ.
  • Last-Event-ID replay — On reconnect the browser sends the Last-Event-ID header automatically, and the server replays what was missed. This requires a durable per-user log to replay from — which pushes you toward Redis Streams.

For SRVJ, the refetch path wins: the durable store already exists, so the realtime layer is free to be lossy.

Pub/Sub vs Redis Streams

Redis Pub/Sub is fire-and-forget — no subscriber connected at publish time means the message is dropped, with no replay and no acknowledgement. That's acceptable here precisely because the REST list reconciles anything lost.

If you ever need "no notification missed in realtime, even across reconnects, without a refetch," move the channel to Redis Streams (XADD + consumer groups + XACK). You get at-least-once delivery and replay via XRANGE, at the cost of a trimming policy (MAXLEN), consumer-group bookkeeping, and more memory. Reach for it only when the refetch model stops being good enough — not before.

Why I Chose SSE

For server-generated notifications, SSE provided:

  • Native browser support.
  • Automatic reconnection.
  • Simple architecture.
  • Lower operational complexity.
  • Lightweight server-to-client communication.
  • Seamless integration with existing HTTP infrastructure.

SSE is not a replacement for WebSockets, but for notification delivery in SRVJ, it turned out to be the right tool for the job.

Next in the series: How CRDTs and Yjs power collaborative editing in SRVJ.