Post 10

Realtime Without a Realtime Service

Before you add Pusher, Ably, or a socket server: Postgres already has a pub/sub channel, and the browser has had Server-Sent Events for a decade. Here is a complete realtime pipeline — LISTEN/NOTIFY to SSE to the UI — with reconnection handled.

Jan 15, 2026/12 min readArchitecture
ShareY
Realtime Without a Realtime Service

Most “we need realtime” requirements are modest: a notification badge that updates, a dashboard number that ticks, a “someone else edited this” banner. You do not need a WebSocket mesh and a third-party bill for that. Postgres has LISTEN/NOTIFY, and Server-Sent Events give you a one-way server-to-client stream over plain HTTP with automatic reconnection built into the browser.

The pipeline

text
1DB write  ──▶  Postgres NOTIFY 'events', payload23          a listening connection in your app45          push into a per-user in-memory bus67          SSE Route Handler streams it to the browser89          EventSource in the client updates React state

1. Emit from the database

migrations/notify_on_notification.sql
1create or replace function notify_notification() returns trigger as $$2begin3  perform pg_notify(4    'app_events',5    json_build_object(6      'type', 'notification',7      'userId', NEW.user_id,8      'id', NEW.id9    )::text10  );11  return NEW;12end;13$$ language plpgsql;14 15create trigger notification_created16  after insert on notifications17  for each row execute function notify_notification();
Watch out

pg_notify payloads are capped at 8000 bytes. Send an id and a type, never the whole row. The client fetches the detail if it needs it — which also keeps your authorization checks in one place.

2. One listener, fanning out to subscribers

Open exactly one dedicated Postgres connection for LISTEN — not one per user. It feeds an in-process event bus that SSE handlers subscribe to.

lib/realtime/bus.ts
1import { EventEmitter } from "node:events";2import { Client } from "pg";3 4const bus = new EventEmitter();5bus.setMaxListeners(0);6 7let started = false;8export async function ensureListener() {9  if (started) return bus;10  started = true;11 12  const client = new Client({ connectionString: process.env.DATABASE_URL });13  await client.connect();14  await client.query("LISTEN app_events");15 16  client.on("notification", (msg) => {17    if (!msg.payload) return;18    const event = JSON.parse(msg.payload) as AppEvent;19    bus.emit(`user:${event.userId}`, event);20  });21 22  client.on("error", async () => {23    started = false;24    setTimeout(ensureListener, 1000); // reconnect the listener itself25  });26 27  return bus;28}
Note

This keeps state in one Node process, which is fine for a single long-lived server or a small fleet with sticky routing. On wide serverless (many short-lived instances) the in-memory bus does not fan out — there you do want Postgres logical replication into a broker, or a hosted realtime service. Know which world you are in.

3. The SSE endpoint

app/api/stream/route.ts
1import { ensureListener } from "@/lib/realtime/bus";2import { getCurrentUserId } from "@/lib/auth";3 4export const dynamic = "force-dynamic";5 6export async function GET(req: Request) {7  const userId = await getCurrentUserId();8  const bus = await ensureListener();9  const encoder = new TextEncoder();10 11  const stream = new ReadableStream({12    start(controller) {13      const send = (event: AppEvent) =>14        controller.enqueue(15          encoder.encode(`data: ${JSON.stringify(event)}\n\n`),16        );17 18      bus.on(`user:${userId}`, send);19 20      // heartbeat keeps proxies from killing an idle connection21      const ping = setInterval(22        () => controller.enqueue(encoder.encode(": ping\n\n")),23        25_000,24      );25 26      req.signal.addEventListener("abort", () => {27        clearInterval(ping);28        bus.off(`user:${userId}`, send);29        controller.close();30      });31    },32  });33 34  return new Response(stream, {35    headers: {36      "content-type": "text/event-stream",37      "cache-control": "no-cache, no-transform",38      connection: "keep-alive",39    },40  });41}

4. The client — and reconnection for free

EventSource reconnects automatically with backoff when the connection drops. You do not write that logic — you just re-sync state on reopen.

hooks/use-live-events.ts
1"use client";2import { useEffect } from "react";3 4export function useLiveEvents(onEvent: (e: AppEvent) => void) {5  useEffect(() => {6    const es = new EventSource("/api/stream");7 8    es.onmessage = (msg) => onEvent(JSON.parse(msg.data));9    es.onerror = () => {10      // browser will retry on its own; nothing to do but wait11    };12 13    return () => es.close();14  }, [onEvent]);15}16 17// usage18useLiveEvents((e) => {19  if (e.type === "notification") {20    queryClient.invalidateQueries({ queryKey: ["notifications"] });21  }22});
Tip

Treat the event as a nudge, not the data. When one arrives, invalidate a React Query key or call router.refresh() — let your normal fetching path re-read through its auth and caching. The realtime channel says “something changed,” your data layer says “here is what it is now.”

When to actually reach for a service

  • Bidirectional, low-latency — multiplayer cursors, collaborative editing, games. SSE is one-way; you would be pairing it with POSTs and it gets awkward. Use WebSockets.
  • Presence at scale — “42 people viewing” across a large fleet needs shared state you do not want to build.
  • You are fully serverless and wide. The in-memory bus does not survive that topology.

Everything else — the badge, the ticker, the stale-content banner — Postgres and SSE cover with about a hundred lines and no new vendor.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.