Messaging

Outbox in Postgres: concurrent workers vs reading the WAL

Milan Jovanović compared two ways to drain a transactional outbox on PostgreSQL: polling workers and a reader streaming the write-ahead log through logical replication. Both move thousands of messages per second, so the real split is latency against operational cost — a replication slot, WAL held on disk and heavier duplicate replays.

Milan Jovanović [email protected] (Milan Jovanović)
Published
Reading time
9 min read
agent run #26
agent run #26
Go to original milanjovanovic.tech

Report this story

Is something wrong with the summary, or do you own this content? Your message comes straight to my inbox.

Type of message

Key points

  • FOR UPDATE SKIP LOCKED lets workers claim batches of up to 1,000 rows without collisions, even across separate processes; the transaction stays open until the broker confirms.
  • Five workers drained ~23,000 messages per second, but a new message still waited 419 ms on average at 100 orders per second because of the one-second sleep after an empty batch.
  • The Npgsql reader — LogicalReplicationConnection, a pgoutput slot — measured 0.83 ms commit-to-consumer and ~31,000 messages per second, about 57,600 without the batched UPDATE.
  • Call SetReplicationStatus with commit.TransactionEndLsn only after the broker confirms, so a crash replays messages instead of losing them.
  • NOTIFY sent from a trigger serializes commits: throughput fell from 8,070 to 256 orders per second with 100 concurrent users.
  • A replication slot keeps WAL on disk while the reader is down; max_slot_wal_keep_size caps it, but Postgres invalidates the slot past that limit.

Milan Jovanović compared two ways to drain a transactional outbox on PostgreSQL: polling workers and a reader that streams the write-ahead log through logical replication. In one-minute backlog drains a single worker published about 15,000 messages per second, five workers reached roughly 23,000, and the WAL reader about 31,000. Latency separated them: at 100 orders per second five workers averaged 419 ms because they sleep a second after an empty batch, while the reader stayed under a millisecond.

The WAL path costs more to run: wal_level=logical, a publication, a replication slot that pins WAL on disk while the reader is down, and a cleanup job. One slot cannot be scaled out. A hard kill replayed 31,095 duplicates against 4,536 for the workers, so consumers must be idempotent. NOTIFY sent from a trigger is the variant to avoid: it took the API down to 256 orders per second.

Real-world use

  • An order or payment API that must publish an event when the database transaction commits, even if the broker is briefly unavailable.
  • A high-volume ingestion service that drains a backlog and needs more throughput by adding workers inside the same deployment.
  • A notification or pricing feed where the gap between commit and consumer has to stay under a few milliseconds.
  • A system where empty claim queries (about 289,000 a minute with five workers) are burning Postgres CPU and you want an idle-cost-free reader.

Why it matters

Polling keeps the outbox simple and scales out by adding workers; reading the WAL is the option when commit-to-consumer latency actually matters, paid for with a replication slot, WAL disk management and more duplicated sends.

This summary was generated by the agent from the original post on Milan Jovanović. The full text and all rights belong to its author and publisher. Go to original

Related stories

Feedback

Found a bug or have an idea? Your message lands straight in my inbox.

Type of message