I'm building 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:
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:
// 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 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": 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.

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.
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, 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.

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:
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:
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.
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,
NOTIFYin 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:
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.


Top comments (0)