A record of wiring up real-time broadcast without an external broker, using PostgreSQL LISTEN/NOTIFY and Bun WebSocket
A few months ago, the team asked for "real-time order status updates on the admin screen." I was about to reach for Redis Pub/Sub when I paused. PostgreSQL was already in the stack — did we really need to add a broker? A quick search revealed that PostgreSQL has a built-in pub/sub mechanism called LISTEN/NOTIFY. I had been using PostgreSQL for years without ever properly trying this feature.
This post is about connecting PostgreSQL's LISTEN/NOTIFY to Bun's built-in WebSocket API to implement real-time broadcasting without a separate broker like Redis. After actually deploying it, there were cases where it worked well with very thin code, and other cases where it became clear that "this wasn't a problem PostgreSQL should solve in the first place." I want to lay out that boundary clearly.
There's Already a Pub/Sub Inside PostgreSQL
Core Behavior of LISTEN/NOTIFY
LISTEN/NOTIFY is an asynchronous pub/sub mechanism built into PostgreSQL. No channel creation is needed in advance — any arbitrary string can be used immediately.
-- Subscribe (from the client-side session)
LISTEN order_updates;
-- Publish (from another session or trigger)
NOTIFY order_updates, '{"id": 42, "status": "shipped"}';
-- Same as above in function form (commonly used inside triggers)
SELECT pg_notify('order_updates', '{"id": 42, "status": "shipped"}');The one characteristic I liked most: NOTIFY is delivered only after the transaction commits. If the transaction rolls back, the notification is not published. It sounds simple, but it's a significant advantage in practice. The inconsistency of "data was saved but the event never fired" structurally cannot occur.
The payload limit is 8,000 bytes. Rather than embedding large JSON directly, the safer pattern is a "thin notification" — send only the record ID and have the receiver re-query the actual data with SELECT.
Overall Architecture
Having each WebSocket client open its own direct LISTEN connection to PostgreSQL is not a good approach. Instead, a single Bun process holds one dedicated connection to PostgreSQL for LISTEN, and fans out to WebSocket clients within the same process.
One important caveat: when the Bun process restarts or the network drops, the LISTEN registration is lost. Re-registering LISTEN on reconnect is mandatory. Miss this and events will silently disappear even though the connection looks alive. I missed this at first and spent quite a while debugging "why aren't events coming through?"
Streaming Order Status Changes in Real Time
Publishing Events via PostgreSQL Trigger
This trigger automatically publishes an event whenever an order status changes. Adding a WHEN clause that fires only when the value actually differs prevents unnecessary NOTIFY spam.
CREATE OR REPLACE FUNCTION notify_order_update()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'order_updates',
json_build_object(
'id', NEW.id,
'status', NEW.status
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER order_status_trigger
AFTER UPDATE ON orders
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION notify_order_update();Stable LISTEN Connections with pg-listen
A single pg driver client can handle LISTEN, but implementing reconnect logic manually quickly gets messy. pg-listen cleanly solves this with built-in auto-reconnect and error events.
Wrapping it in a factory that takes channel names as parameters — rather than hardcoding them — makes it reusable across other domains.
// listener.ts
import createSubscriber, { type Subscriber } from "pg-listen";
type NotificationHandler = (channel: string, payload: unknown) => void;
export function createPgListener(
channels: readonly string[],
onNotification: NotificationHandler
) {
const subscriber: Subscriber = createSubscriber({
connectionString: process.env.DATABASE_URL,
});
for (const channel of channels) {
subscriber.notifications.on(channel, (payload) => {
onNotification(channel, payload);
});
}
// pg-listen keeps retrying reconnects, but if there's no listener on the
// EventEmitter 'error' event, Node/Bun will crash. Always attach at least one.
subscriber.events.on("error", (err) => {
console.error("PostgreSQL listener error:", err);
});
subscriber.events.on("connected", () => {
console.log("PostgreSQL listener connected");
});
return {
async start() {
await subscriber.connect();
for (const channel of channels) {
await subscriber.listenTo(channel);
}
},
async stop() {
await subscriber.close();
},
};
}pg-listen retries reconnection internally with exponential backoff. Even so, in the extreme case where the DB stays down long enough to exhaust all retries, it's safer to define the application's behavior at the process orchestrator level (systemd, PM2, Kubernetes). My policy is to exit the process after "error" fires beyond a certain count, letting the orchestrator restart it fresh.
Wiring Up the Bun WebSocket Server
Bun ships with ws.subscribe(topic) / server.publish(topic, data) / ws.unsubscribe(topic) out of the box. Topic-based fanout without any additional library is one of Bun WebSocket's strengths.
// server.ts
import { createPgListener } from "./listener";
const CHANNELS = ["order_updates"] as const;
const server = Bun.serve({
port: Number(process.env.PORT) || 3000,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/ws") {
const upgraded = server.upgrade(req);
if (!upgraded) {
return new Response("WebSocket upgrade failed", { status: 400 });
}
return;
}
return new Response("Not Found", { status: 404 });
},
websocket: {
open(ws) {
ws.subscribe("order_updates");
ws.send(JSON.stringify({ type: "connected" }));
},
message(ws, message) {
// Handle client messages if needed
},
close(ws) {
ws.unsubscribe("order_updates");
},
},
});
const pgListener = createPgListener(CHANNELS, (channel, payload) => {
server.publish(channel, JSON.stringify({ type: "update", data: payload }));
});
await pgListener.start();
console.log(`Server started: http://localhost:${server.port}`);How Events Actually Flow
Authentication Required When Scaling to Multi-Tenant
In a SaaS context, isolating by tenant-specific channels is a natural pattern. But there's a trap here. If you trust a tenantId received as a query parameter and attach a channel subscription directly, anyone can pass ?tenantId=other_company and receive another tenant's events. Using only the verified tenant ID from an auth token as the channel name is the only correct answer.
Below is a conceptual example of extracting a tenant ID from a JWT. In actual production, signature verification via a library (e.g., jose) is mandatory.
// Assumes verifyToken verifies the signature and returns { tenantId }
async function authenticate(req: Request): Promise<{ tenantId: string } | null> {
const token = new URL(req.url).searchParams.get("token");
if (!token) return null;
try {
return await verifyToken(token); // throws on invalid signature
} catch {
return null;
}
}
const server = Bun.serve<{ tenantId: string }>({
async fetch(req, server) {
const url = new URL(req.url);
if (url.pathname !== "/ws") {
return new Response("Not Found", { status: 404 });
}
const auth = await authenticate(req);
if (!auth) return new Response("Unauthorized", { status: 401 });
const upgraded = server.upgrade(req, { data: { tenantId: auth.tenantId } });
if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
return;
},
websocket: {
open(ws) {
ws.subscribe(`tenant_${ws.data.tenantId}_events`);
},
close(ws) {
ws.unsubscribe(`tenant_${ws.data.tenantId}_events`);
},
message() {},
},
});On the DB side, use the verified tenant ID directly as the channel name segment.
PERFORM pg_notify(
'tenant_' || NEW.tenant_id || '_events',
json_build_object('type', 'order_update', 'id', NEW.id)::text
);Limitations to Keep in Mind
Comparison: Pros and Cons
| Item | PostgreSQL LISTEN/NOTIFY | Redis Pub/Sub |
|---|---|---|
| Separate infrastructure | Not needed | Redis server required |
| Transactional safety | Published only on commit | No built-in guarantee |
| Message persistence | None (lost if subscriber is offline) | None |
| Payload limit | 8,000 bytes | Practically unlimited |
| Connection scalability | Overhead in high-connection environments (improvement planned in PG 19) | Relatively strong |
| Cross-DB/cluster | Not supported | Supported |
| Connection pooler (transaction mode) | LISTEN not supported, dedicated connection required | N/A |
| Multi-app-instance fanout | Each instance subscribes independently (see below) | Natural |
When This Combination Is Enough — and When It Isn't
The reason "guaranteed message delivery" is the first branch is clear. With LISTEN/NOTIFY, if a subscriber is offline, the notification simply vanishes. There is no way to recover missed events after reconnecting. Systems that cannot tolerate this behavior should consider Kafka or a combination of pg_notify + a separate event table (polling or outbox pattern).
The Wall You Will Hit with Horizontal Scaling
This is the point I need to address most honestly in this post. The moment you run two or more Bun instances, each instance has its own LISTEN connection and a server.publish() scope valid only within its own process. PostgreSQL delivers NOTIFY to all instances, so events do reach each one. This means clients scattered across multiple instances will still receive broadcasts.
The problem arises when you try to share derived state between instances. Things like per-event counters, presence information (who is online), or custom events that need to relay between instances are all trapped in each process's local memory. Additionally, since NOTIFY is processed once per instance, if you need a work queue where "only one of the servers that received this event performs the follow-up action (e.g., sending an email)," you need a broker at that point.
In summary:
- Pure broadcast (every subscriber receives the same event) → LISTEN/NOTIFY covers this even with multiple instances.
- Workloads that must be processed exactly once without duplication → LISTEN/NOTIFY is insufficient.
- State sharing across instances → A shared store like Redis is needed.
Common Pitfalls in Practice
Attempting LISTEN through PgBouncer in transaction mode
LISTEN does not work correctly through PgBouncer's transaction mode. LISTEN connections must use a dedicated long-lived connection that bypasses the pool. This is also why pg-listen manages its own connection.
Forgetting to re-register LISTEN after reconnect
If you don't re-execute LISTEN after a reconnect, the connection is alive but the subscription is gone. pg-listen re-attaches the originally registered channels after reconnect, so you don't have to worry about this.
Sending the full record in the payload
The 8,000-byte limit fills up faster than you'd expect. Make it a habit to send only the ID and minimal identifiers, and have the receiver re-query with SELECT.
What Changes in PostgreSQL 19 (as of 2026)
The existing NOTIFY implementation wakes up all backend processes — even those not subscribed to the channel — when a notification is published. This was a noticeable overhead in high-connection environments and has been a point of criticism from observers like Simon Willison. As of 2026, PostgreSQL 19 is working on a change to address this, selectively waking only the backends actually subscribed to the channel.
One thing not to misunderstand: this improvement is about reducing CPU overhead in high-connection environments, not increasing raw message throughput for NOTIFY. If you need raw throughput on the order of thousands of messages per second, it's too early to conclude this improvement alone makes LISTEN/NOTIFY a broker replacement. For the exact release timeline and final change details, check the release notes at time of deployment.
Back to That Order Screen
Returning to the original requirements: the goal was to show order status changes in real time on a handful of admin screens. Concurrent users were proportional to team size, event frequency was a few per second, only broadcasting was needed, and there was no concern about duplicate processing. Under these conditions, one trigger, one pg-listen file, and one Bun server file got the admin screen updating in real time. No Redis container was added, no new box appeared in the infrastructure diagram, and the number of components to check during an incident didn't increase by one. That was the biggest gain from this combination.
On the other hand, when a notification retry requirement came in, we switched to a structure where an outbox table and polling worker sit in front, with LISTEN used only as a supplement to make polling more immediate. In the end, the right tool is determined by the situation. The approach of first making use of what's already in the stack, and only adding a broker to cover the specific gap once that gap becomes clear, has rarely led us astray.