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.

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
1DB write ──▶ Postgres NOTIFY 'events', payload2 │3 a listening connection in your app4 │5 push into a per-user in-memory bus6 │7 SSE Route Handler streams it to the browser8 │9 EventSource in the client updates React state1. Emit from the database
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();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.
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}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
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.
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});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.
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.