The moment a feature needs to update live, a live counter, a presence indicator, a "new message" badge, an activity feed, the reflex is to reach for a websocket service. Pusher, Ably, a Socket.IO server, a stateful Node process parked next to your stateless app. That is one more thing to deploy, scale, secure, and pay for, and it exists mostly to move small events from one place to a bunch of connected browsers.
If your data already lives in Postgres, you already have a message bus for that. Postgres ships with LISTEN and NOTIFY, a lightweight publish/subscribe system built into the database. Pair it with server-sent events from a serverless function and you can fan realtime updates out to every connected client without standing up any realtime infrastructure at all. In this post I build exactly that on a Neon Function, explain the one part that is subtle on serverless, and prove it works with two live subscribers. The
// One dedicated LISTEN connection per isolate. LISTEN needs a real session,
// so use the DIRECT (unpooled) URL, not the transaction pooler.
const listener = new Client({ connectionString: env.postgres.databaseUrlUnpooled });
await listener.connect();
await listener.query('LISTEN counter_updates');
// SSE connections held open by THIS isolate.
const clients = new Set<ReadableStreamDefaultController<Uint8Array>>();
listener.on('notification', (msg) => {
const frame = new TextEncoder().encode(`data: ${msg.payload}\n\n`);
for (const c of clients) c.enqueue(frame); // push to this isolate's browsers
});
The write path is a normal query plus a NOTIFY:
app.post('/increment', async (c) => {
const [row] = await db
.insert(counters)
.values({ id: 1, value: 1 })
.onConflictDoUpdate({ target: counters.id, set: { value: sql`${counters.value} + 1` } })
.returning({ value: counters.value });
// Fan the new value out to every isolate, and thus every browser.
await pool.query('SELECT pg_notify($1, $2)', ['counter_updates', String(row.value)]);
return c.json({ value: row.value });
});
And the SSE endpoint just registers the browser and streams:
app.get('/events', async (c) => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
clients.add(controller);
// send the current value immediately so a new tab is correct on load
readCount().then((v) => controller.enqueue(encode(`data: ${v}\n\n`)));
},
cancel() {
/* remove this controller from clients */
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
});
});
LISTENholds a session-level subscription, which the transaction pooler (PgBouncer in transaction mode) does not support. Use the direct, unpooled connection string for the listener (Neon injects it asDATABASE_URL_UNPOOLED). Keep using the pooled URL for your normal queries. Getting this wrong is the usual reason "notifications never arrive."
Proving it works
I deployed the counter as a Neon Function and connected two independent SSE subscribers, then fired three increments. Every subscriber should see its starting value on connect and then each new value as it happens. Here is the actual run:
Wrapping up
Realtime does not always mean a websocket service. For the common cases, a live number, a badge, a feed, an activity stream, Postgres LISTEN/NOTIFY is a pub/sub you already run, and SSE from a serverless function is enough to get those events to the browser. On Neon the function lives on the branch next to Postgres, so the listener connection is a local hop and the whole realtime path is one deploy, no separate service to operate. Reach for a real broker or websockets when you need durability or two-way low latency; reach for this when you just want the UI to update and would rather not run another box to make it happen.
SOCIAL SHARE CARD GENERATOR