# LISTEN/NOTIFY performance in PostgreSQL

> A NOTIFY in the transaction capped PostgreSQL 18.6 and 19 RC1 at about 3,900 commits per second, with 8 clients or 64. Sending notifications in batches after commit brought 64 clients back to 50,000.

2026-11-04 · Wojciech Wentland · https://blog.wentland.io/blog/listen-notify-performance-postgresql/

I'm building [Corfedo Work](https://blog.wentland.io/projects/corfedo-work/), a .NET library for background jobs that keeps its state in the
application's own PostgreSQL database. Its workers need to know when something changed: a job became due, or a job
somebody is waiting for has ended. The PostgreSQL answer is `NOTIFY` in the transaction that made the change. I
designed the library's signals around it.

Then I measured it. Single-row transactions with a `NOTIFY` committed about 3,900 times per second with 8 clients,
and about 3,900 with 64. Without the `NOTIFY`, the same transactions reached 16,700 and 56,700.

## Signals that ride the transaction

A signal in Work says "something you wait for may have changed, read again". It's a topic and a key, with no data.
The application sends it in the same transaction as the change:

```csharp
static readonly SignalTopic Pending = new("orders.pending");

await using var tx = await db.Database.BeginTransactionAsync(ct);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await signals.SendAsync(db.Database.WorkTransaction(), Pending, order.TenantId, ct);
await tx.CommitAsync(ct);
```

The worker on the other side treats a signal as a reason to look, never as the change itself:

```csharp
// Subscribed with signals.Subscribe(Pending, worker).
sealed class OrderWorker : ISignalSubscriber
{
    // A tenant's orders may be pending: read again.
    public void Received(string key) => Wake();

    // After every (re)connection signals may have been missed: read everything.
    public void Resync() => Wake();
}
```

Its loop reads the pending orders, then sleeps until the next wake or a 30-second fallback poll, whichever comes first.

What made `NOTIFY` attractive is when the message goes out. PostgreSQL delivers it after the transaction commits, and
never if the transaction rolls back. A worker woken by it reads the committed row, not an older version, and nobody
wakes for a change that was undone. I didn't have to write any of that.

I wasn't the first to hit the ceiling. Recall.ai [traced three outages in four days](https://www.recall.ai/blog/postgres-listen-notify-does-not-scale)
in March 2025 to a `NOTIFY` on a busy write path and moved the logic out of the database. In July 2026, DBOS published
the opposite headline, ["Postgres LISTEN/NOTIFY can actually scale"](https://www.dbos.dev/blog/postgres-listen-notify-scalability):
their stream writes went from 2,900 to 60,000 per second once they buffered notifications and sent them in batches.
Recall.ai's post now says a PostgreSQL commit removed the bottleneck. DBOS says the same change, which ships in
PostgreSQL 19, doesn't. Before choosing a side for a job library I wanted to see where the limit comes from.

## The ceiling

Setup: Ryzen 7 7700X with an NVMe drive, PostgreSQL 18.6 and 19 RC1 in Docker with `fsync` on, a .NET 10 client on
the same machine. Each transaction inserts one row; two sessions listen. Wake-up is the time from a commit to the
first listener receiving its message. Medians of three 8-second runs.

[Figure: Single-row transactions, two listening sessions. PostgreSQL 18.6; 19 RC1 is within 3% at every point.]

| Clients | No NOTIFY | NOTIFY in the transaction | Median wake-up |
|---|---|---|---|
| 1 | 2,711 | 2,456 | 0.37 ms |
| 8 | 16,663 | 3,960 | 2.0 ms |
| 32 | 42,179 | 3,923 | 8.0 ms |
| 64 | 56,700 | 3,925 | 16.1 ms |

The flat line is the finding. More clients don't add commits; they add waiting, which is why the wake-up time grows
from 2 ms to 16 ms. The notifications arrive later because the transactions sending them queue up.

## What PostgreSQL does at commit

Notifications go into one queue shared by every database in the cluster, in commit order. To keep that order, a
transaction that has notifications takes a lock before it commits. It shows up in the logs as
`AccessExclusiveLock on object 0 of class 1262 of database 0`, the line Recall.ai found. The transaction holds it until
it ends, which is after its commit record has been flushed to disk. So two notifying transactions never commit at the
same time.

That alone would be a queue. What makes it expensive is group commit. Normally, sessions that commit at the same
moment share one WAL flush: in my runs one flush covered 4 commits at 8 clients and 19 at 64. With `NOTIFY` it covered
exactly one, at any client count.

[Figure: Schematic, not to scale.]

The database statistics at 64 clients:

| | No NOTIFY | NOTIFY in the transaction |
|---|---|---|
| Commits per second | 56,700 | 3,925 |
| Database CPU | 4.35 cores | 0.73 cores |
| Commits per WAL flush | 19.1 | 1.0 |
| Most common wait of active sessions | `LWLock:WALWrite` 72% | `Lock:object` 98% |

At the ceiling the database is nearly idle. Almost every active session waits on that one lock, and a faster CPU
wouldn't move the number. My reading is that the ceiling follows how long one WAL flush takes: here about 3,550
flushes per second for 3,925 commits. I haven't run it on another disk.

Listeners pay too. Each notification wakes every listening session, and each one runs a short transaction to read the
queue. At 8 clients the database ran 10,900 transactions per second to commit 3,960 of mine.

## PostgreSQL 19

The commit Recall.ai links, [`282b1cd`](https://github.com/postgres/postgres/commit/282b1cde9dedf456ecf02eb27caf086023a7bb71),
adds a shared map of which session listens on which channel. A session that doesn't listen on the notified channel is
no longer woken. Its message says nothing about the commit lock.

[Figure: Median of three runs, 64 clients, two listening sessions on one channel.]

On one channel with two listeners, 19 RC1 matches 18.6 within 3% in every mode: 3,912 commits per second with
`NOTIFY` in the transaction at 64 clients, `Lock:object` at 98% of waits, one commit per WAL flush.

That isn't the case the patch is for, so I ran the one it is for: 50 listening sessions, each on its own channel, and
every transaction notifying one of them. At 64 clients:

| | 18.6 | 19 RC1 |
|---|---|---|
| Commits per second | 3,963–3,982 | 3,975–3,987 |
| Database transactions per second | 183,246 | 7,265 |
| Database CPU | 2.68 cores | 0.75 cores |
| Active sessions waiting on `Lock:object` | 97% | 98% |

On 18.6, all 50 listeners woke for every notification and read the queue, although 49 of them had nothing to read.
On 19 they stay asleep, and the database does a 25th of the work. The commit rate doesn't move.

PostgreSQL 19 removes a real cost, the one that grows with the number of listening sessions. The waits in Recall.ai's
logs were on the commit lock, though, and in my runs that lock behaves the same in 19 RC1 as in 18.6. If your
notifying transactions queue on commit today, 19 won't let more of them commit per second.

## Sending after commit

If the lock is taken by transactions that notify, the fix is to have fewer of them. DBOS buffers notifications in
memory and sends them in batches, so only the batch takes the lock. I did the same, except that Work, as a library,
doesn't own the transaction. The application begins it, maybe through Entity Framework, and
commits it whenever it likes. There's no commit callback I can rely on.

What a library can get is the transaction's ID. The sending statement returns it, at no extra round trip:

```sql
INSERT INTO job_ends (job_id) VALUES ($1) RETURNING pg_current_xact_id();
```

The process keeps the ID and the message in memory. One background sender per process then asks PostgreSQL which of
those transactions committed and notifies only those, in one statement:

```sql
WITH batch AS MATERIALIZED (
    SELECT xid, message, pg_xact_status(xid) AS status
    FROM unnest($1::xid8[], $2::text[]) AS b (xid, message)
), sent AS MATERIALIZED (
    SELECT count(pg_notify('work', message)) AS n
    FROM (SELECT DISTINCT message FROM batch WHERE status = 'committed') d
)
SELECT array(SELECT xid FROM batch WHERE status = 'in progress'),
       array(SELECT message FROM batch WHERE status = 'in progress')
FROM sent;
```

Committed transactions get their messages sent, duplicates collapse into one, rolled-back ones are dropped, and the
ones still running come back to be checked again later.

```mermaid
sequenceDiagram
    participant A as App
    participant S as Sender
    participant P as PostgreSQL
    participant L as Listener
    A->>P: INSERT, returns xid
    A->>S: keep (xid, message)
    A->>P: COMMIT, no lock
    Note over S: 2 ms later
    S->>P: notify committed xids
    P-->>S: xids still running
    P-->>L: one per message
```

The first version had a bug that cost me about 30 signals per run. It read the statuses in two statements: one sent
the committed messages, the next returned the ones still running. A transaction that committed between those two
statements was in neither list, and its message was gone. Reading each status once per batch, in one materialized CTE,
fixed it.

The other setting is how often to send. A sender that flushes whenever anything is waiting turns into a notifying
transaction itself, up to 2,900 times a second here, and queues for the same lock. A minimum gap between batches
trades a little wake-up time for throughput:

| Clients | Flush when anything waits | At least 2 ms apart | At least 5 ms apart |
|---|---|---|---|
| 1 | 2,645 · 1.2 ms | 2,699 · 2.1 ms | 2,667 · 3.6 ms |
| 8 | 16,280 · 0.6 ms | 16,763 · 1.9 ms | 16,791 · 2.9 ms |
| 32 | 37,566 · 0.9 ms | 39,279 · 2.3 ms | 39,521 · 4.2 ms |
| 64 | 47,144 · 1.4 ms | 49,800 · 2.7 ms | 50,194 · 4.5 ms |

Commits per second · median wake-up, PostgreSQL 18.6.

The difference is smaller than I expected, 3–5%. I kept 2 ms. With it, 64 clients commit about 50,000 times per
second, 88% of the rate without notifications, and the median wake-up is 2.7 ms instead of 16.

This is how Work sends signals now, in place of `NOTIFY` in the transaction. Through the library's API, 64 clients
that each insert a row and send a signal went from 3,972 to 40,204 commits per second. Batching costs 2–4% of that
against the same statement without a signal; the bigger cost is the statement itself, since an application's send is
a round trip of its own. Work's internal statements return the transaction ID with the rows they change and skip it.

## What it costs

- A process that crashes after the commit and before the batch loses those signals. Readers need the fallback poll.
  Work already had one; with batching it stops being optional.
- A transaction ID can't tell a rollback to a savepoint apart. A message recorded after a savepoint that was later
  rolled back still goes out if the transaction commits. The reader wakes, finds nothing and goes back to waiting. I
  changed Work's signal contract to allow this.
- Wake-ups are later at low load. With one client, `NOTIFY` in the transaction woke the listener in 0.37 ms and the
  batch in 2.1 ms, at the same throughput.
- A transaction that sends gets a transaction ID even if it wrote nothing else.
- There's a background sender per process to write, test and shut down cleanly.

## When I'd keep NOTIFY in the transaction

If the application commits a few hundred notifying transactions per second, far below the ceiling, I'd keep it. It's
less code, it wakes faster, and the transaction guarantees are exact. The ceiling I measured is per cluster, though,
not per application: every notifying transaction on the server shares it.

Whatever the approach, notify only on changes somebody waits for. Work signals a job's end only when a caller is
waiting for that job; signalling every row change would put every commit in the queue for the lock.

To see whether you're already there, look at the waits:

```sql
SELECT count(*) FROM pg_stat_activity
WHERE wait_event_type = 'Lock' AND wait_event = 'object' AND state = 'active';
```

A steady count during write bursts, together with low CPU, is the signature I saw in every run with `NOTIFY` in the transaction.

I haven't run this on a cloud volume, where a WAL flush takes longer than on a local NVMe drive. If the ceiling
follows flush time, as the numbers here suggest, it would sit lower there.

The other run I want is one without `NOTIFY` at all. A transactional message written with `pg_logical_emit_message`
reaches a logical decoding consumer only if its transaction commits. It should also vanish with a rolled-back
savepoint, the guarantee batching gave up, but I haven't tested that yet. Nor whether one consumer keeps up with
50,000 commits a second.
