Postgres LISTEN/NOTIFY: Real-Time Events Without a Message Broker

PostgreSQL has had a real-time pub/sub mechanism since the 1990s and most application code does not know it exists. LISTEN/NOTIFY is a quiet, reliable, and load-appropriate alternative to running Redis or Kafka for many real-time use cases that small SaaS teams encounter.

Most teams reaching for a real-time event mechanism in 2026 default to Redis pub/sub, Kafka, or a managed service like Pusher or Ably. The mental model is that real-time events require a separate piece of infrastructure designed for the purpose. The mental model is mostly wrong for small-to-medium use cases. PostgreSQL has had a quiet, reliable, in-database pub/sub mechanism since the 1990s, and the modern version handles tens of thousands of notifications per second on commodity hardware. For many real-time use cases that small SaaS teams encounter, LISTEN/NOTIFY is the right primitive: it removes a piece of infrastructure, it integrates with the database transactions you already trust, and it imposes almost no operational cost.

This post covers what LISTEN/NOTIFY actually does, where it earns its place over external pub/sub infrastructure, where it does not, and the patterns that hold up in production. The patterns apply across the four products in our studio — DocuMint, CronPing, FlagBit, and WebhookVault — and apply to any system that uses PostgreSQL as its primary database.

What LISTEN/NOTIFY does

The mechanism is simple. A client connection issues LISTEN channel_name to subscribe to a named channel. Other clients (or the same one) issue NOTIFY channel_name, 'optional payload string' to publish a message to that channel. All connections currently listening on the channel receive an asynchronous notification with the channel name, the publishing process's PID, and the payload. The payload is limited to 8000 bytes by default — enough for a small JSON object or an event ID, not enough for a full document.

The notifications are asynchronous in the libpq sense: they arrive between query results and need to be polled or read from a socket select loop. Most modern PostgreSQL drivers expose them through callback-based APIs that integrate with the application's event loop. Python's psycopg2 and psycopg3, Node.js's pg, Go's pgx, and Rust's tokio-postgres all have first-class support.

The transaction integration is the key feature. When a NOTIFY is issued inside a transaction, the notification is queued and not delivered until the transaction commits. If the transaction rolls back, the notification is silently dropped. This is exactly the right semantics for the common case of "notify subscribers when this row is inserted": the notification is delivered if and only if the row commits, with no possibility of a notification arriving for a row that doesn't exist or no notification arriving for a row that does. External pub/sub infrastructure cannot match this guarantee without elaborate coordination.

The use cases that fit

LISTEN/NOTIFY fits use cases where the events are produced as a side effect of database transactions and consumed by services that benefit from immediate notification but can tolerate a missed event. Some examples:

Configuration reload. An admin updates a feature flag in the database. The application processes need to refresh their cached flag values. NOTIFY on a "config_changed" channel; each application process listens and reloads its cache when notified. We use this pattern in FlagBit for invalidating the in-process flag-evaluation cache.

Dashboard real-time updates. A user submits a new monitor in CronPing. The dashboard, currently watched by another tab in the same browser, should update without a manual refresh. NOTIFY on a per-user channel; the SSE endpoint listens for the user's channel and forwards notifications to the browser. We use this pattern in CronPing for real-time monitor status updates.

Worker wakeup. A new job is inserted into a job queue table. Worker processes that were sleeping waiting for work need to wake up and check the queue. NOTIFY on a "jobs_pending" channel after the insert; workers LISTEN and trigger a queue check on every notification. This is faster than polling the queue and uses fewer resources because the workers are blocked on the socket rather than spinning. We use this pattern in WebhookVault for the webhook delivery worker pool.

Cache invalidation. An external cache (Redis, in-process) is invalidated when the underlying data changes. NOTIFY on a per-resource channel after the row is updated; cache invalidator subscribes and removes the entries.

The pattern that ties these together is that the events are produced by database transactions, consumed by application processes, do not require persistence (a missed event is acceptable because the truth lives in the database tables), and have a publish rate well under the LISTEN/NOTIFY throughput limits.

The use cases that don't fit

LISTEN/NOTIFY does not replace external pub/sub for several common cases. It does not persist events: a notification published while no listener is connected is lost. It does not handle very high publish rates: tens of thousands of notifications per second is achievable on good hardware, but hundreds of thousands is not. It does not partition events: every listener on a channel receives every notification, which is wrong for use cases where different consumers should process different events.

The cases where external pub/sub remains the right answer: high-volume event streams (clickstream analytics, telemetry pipelines), event sourcing where every event must be persisted, fan-out patterns where consumers need their own offset and replay capability, and cross-database boundaries where the publisher and consumer don't share a PostgreSQL instance.

The implementation in practice

The publisher side is trivial. A trigger on the relevant table fires a pg_notify() call after every relevant change. The trigger function looks like BEGIN; PERFORM pg_notify('row_changed', NEW.id::text); RETURN NEW; END;. The trigger fires inside the transaction, so the notification is queued and delivered on commit. The application code that does the INSERT or UPDATE doesn't need to know about the notification at all.

The consumer side is slightly more involved because the notifications are asynchronous. The pattern that works: open a dedicated long-lived connection used only for LISTEN, subscribe to the channels, and run a select loop that processes notifications as they arrive. The application's main connection pool is separate; the LISTEN connection is one extra connection per process that stays alive for the process lifetime.

The consumer must also handle reconnection. If the connection drops (network blip, database failover, process restart on the database side), the LISTEN subscription is lost and any notifications during the gap are missed. The discipline is that the consumer reconnects automatically and, on reconnection, performs whatever consistency check is appropriate — re-reading the database state, replaying recent events from a journal table, or simply accepting that some notifications may be missed if the application can tolerate it.

The transactional outbox alternative

For use cases where missed notifications are unacceptable, the transactional outbox pattern combined with LISTEN/NOTIFY is the right combination. Events are written to an outbox table inside the same transaction as the row changes; a LISTEN/NOTIFY notification wakes a worker; the worker reads the outbox in order and processes events; the worker marks events as processed atomically with the side effect.

The combination provides exactly-once-effectively semantics without external infrastructure: the outbox guarantees no event is lost; LISTEN/NOTIFY provides immediate wakeup; the worker's atomic update prevents duplicate processing. The cost is the outbox table and the small additional logic. The benefit is that the system is fully self-contained in PostgreSQL and reasons about consistency with the same primitives the rest of the application already uses.

The performance envelope

LISTEN/NOTIFY performance has improved significantly over PostgreSQL versions. As of 2026 with PostgreSQL 17, the practical envelope is in the range of tens of thousands of notifications per second on a single instance, with notifications arriving at consumers within milliseconds of the publisher's commit. The bottleneck for most teams is not the throughput but the publish rate of the underlying events; if the application is doing thousands of database writes per second, LISTEN/NOTIFY is keeping up.

The tuning knobs are minimal. max_notify_queue_pages controls the in-memory queue size, which matters for handling bursts. The default of 8 (~64KB) is enough for moderate volumes; bump it to 128 or 1024 if the application produces bursts of thousands of notifications. Beyond that, the limits are about CPU and network rather than PostgreSQL configuration.

The operational discipline

Three habits make LISTEN/NOTIFY reliable in production. First, the LISTEN connection must be separate from the application connection pool, because LISTEN connections cannot be returned to the pool while the LISTEN is active. Second, the consumer must handle reconnection cleanly, because connections do drop. Third, the notification handler must be fast: if the handler is slow, notifications back up in the consumer-side queue and eventually overwhelm the connection. The right pattern is for the handler to enqueue the notification on an in-process work queue and return immediately, with separate worker threads doing the actual work.

The deeper observation

The mental model of "real-time events require a separate piece of infrastructure" is a holdover from earlier eras when database support for real-time mechanics was poor or nonexistent. PostgreSQL has had LISTEN/NOTIFY for thirty years, and modern PostgreSQL handles the volumes that small-to-medium SaaS applications produce. Reaching for Redis or Kafka before checking whether LISTEN/NOTIFY suffices is reaching for complexity the application does not yet need. The right discipline is to start with the database primitives, measure the actual event rate, and graduate to external infrastructure only when the measurements show the database can no longer keep up. Most teams never reach that threshold.

Read more