Most teams reaching for Kafka or Redis Pub/Sub to wire real-time events into their application already have the tool they need sitting inside their PostgreSQL database. PostgreSQL's LISTEN/NOTIFY mechanism has shipped in every release since version 6.4, works over your existing database connection, and requires zero additional infrastructure. It handles cache invalidation signals, job queue wake-ups, and live dashboard pushes for the majority of production workloads without a message broker in sight. Understanding when it is the right tool — and when it is not — is the difference between elegant simplicity and a subtle production incident.
This guide covers the mechanics, the patterns that work well, the client library integration, and the specific constraints that matter before you commit LISTEN/NOTIFY to a critical path.
- LISTEN/NOTIFY is built into PostgreSQL — no message broker, no extra infrastructure, no new failure domain.
- Notifications are transactional: a NOTIFY inside a transaction only fires on COMMIT, so you never send a stale event for a rolled-back write.
- The payload cap is 8,000 bytes — send a reference ID in the notification and fetch full data from the DB on the receiving side.
- Notifications are not durable: if every listener is offline when a NOTIFY fires, the event is gone. Design your application to tolerate missed events.
- PgBouncer in transaction pooling mode silently breaks LISTEN — always use a dedicated, unpooled connection for your listener processes.
How PostgreSQL LISTEN/NOTIFY Works
LISTEN/NOTIFY is a lightweight publish-subscribe mechanism built directly into the PostgreSQL wire protocol. When a client issues a LISTEN channel_name command, the server registers that connection as a subscriber on that named channel. Any subsequent NOTIFY channel_name, 'payload' issued by any other session — including a trigger running inside a transaction — will deliver an asynchronous message to every registered listener on that channel.
The mechanics have a few important properties worth understanding precisely:
Notifications are transactional. If you call NOTIFY inside an explicit transaction block, the notification is not dispatched until the transaction commits. A rollback discards the notification entirely. This is a powerful guarantee: you will never receive a notification about a write that never happened. A trigger that fires on INSERT and calls pg_notify() will only send the event if the INSERT transaction commits successfully.
Notifications are not durable. PostgreSQL does not persist notifications anywhere. If the listening connection is not active at the moment a NOTIFY is processed — even by a fraction of a second — the notification is gone. There is no dead-letter queue, no replay, and no way to retrieve missed events. Applications must be designed to handle gaps, typically by performing a full state read when a listener reconnects.
Notifications are delivered asynchronously. After a NOTIFY commits, the server pushes the message out to each listening connection without the sender waiting for acknowledgment. Listeners receive notifications between their own queries — the PostgreSQL driver surfaces them as out-of-band messages on the connection, without requiring the application to poll.
Payload size is limited to 8,000 bytes. This is intentional. NOTIFY is designed to carry a signal and a minimal reference, not a full data payload. The canonical pattern is to include the primary key or a small context object in the notification, then query the actual data from the database on the receiving side.
No guaranteed ordering across channels. Notifications on a single connection are delivered in the order they are received, but across multiple channels, ordering is not guaranteed. Do not build logic that depends on a specific cross-channel sequence.
Basic LISTEN/NOTIFY Usage
SQL Syntax
The simplest form of LISTEN/NOTIFY is directly in SQL. Open two psql sessions to see it in action:
-- Terminal 1: register as a listener
LISTEN order_events;
-- Terminal 2: send a notification
NOTIFY order_events, '{"order_id": 1234, "status": "shipped"}';
-- Terminal 1 will immediately receive:
-- Asynchronous notification "order_events" with payload
-- "{"order_id": 1234, "status": "shipped"}" received from server process with PID 12345.LISTEN is session-scoped. It persists for the lifetime of the connection and is automatically cleaned up when the connection closes — there is no need to explicitly UNLISTEN before disconnecting, though you can use UNLISTEN channel_name or UNLISTEN * to deregister from specific or all channels.
The pg_notify() Function
For use inside PL/pgSQL triggers and functions, the pg_notify() function is the right tool. It accepts the channel name and payload as arguments and can construct dynamic JSON payloads inline:
-- Calling pg_notify() directly from SQL
SELECT pg_notify('order_events', json_build_object('order_id', 1234, 'status', 'shipped')::text);
-- Inside a PL/pgSQL function, use PERFORM to discard the return value
PERFORM pg_notify('order_events', json_build_object('order_id', NEW.id, 'status', NEW.status)::text);The json_build_object() + ::text cast pattern is the cleanest way to construct notification payloads in triggers. It avoids manual string concatenation and handles quoting correctly.
Real-World Patterns
Cache Invalidation
Cache invalidation is the most common LISTEN/NOTIFY use case in production. The pattern: a trigger fires on every INSERT, UPDATE, or DELETE on a table, sends a notification with the affected row's ID and the operation type, and one or more application processes listening on that channel evict the relevant entries from their in-memory or Redis cache.
-- Trigger function that fires on any product table change
CREATE OR REPLACE FUNCTION notify_product_change()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'product_cache_invalidate',
json_build_object(
'operation', TG_OP,
'product_id', NEW.id,
'updated_at', extract(epoch from now())
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER product_change_notify
AFTER INSERT OR UPDATE OR DELETE ON products
FOR EACH ROW EXECUTE FUNCTION notify_product_change();Notice the updated_at epoch timestamp in the payload. This is deliberate — if a listener reconnects after being offline, it can compare this timestamp against the last event it successfully processed to decide whether a full cache warm-up is needed.
Job Queue Wake-Up
LISTEN/NOTIFY is an excellent complement to a PostgreSQL-backed job queue. Rather than having workers poll the jobs table on a fixed interval (wasteful at low load) or run at maximum frequency (expensive at all times), workers LISTEN on a channel and are woken up the instant a new job is available:
-- Trigger that notifies workers when a new job row is inserted
CREATE OR REPLACE FUNCTION notify_new_job()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('job_queue', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER new_job_trigger
AFTER INSERT ON jobs
FOR EACH ROW EXECUTE FUNCTION notify_new_job();The worker receives the job ID in the notification payload, then immediately queries the jobs table with SELECT ... FOR UPDATE SKIP LOCKED to atomically claim the job. The notification is just a wake-up signal — the actual job assignment happens through the normal locking mechanism, which correctly handles multiple workers competing for the same job.
Live Dashboard Updates
For dashboards that need near-real-time data — order counts, active user metrics, inventory levels — LISTEN/NOTIFY lets the server push a data-changed signal to the application tier, which can then re-query the aggregates and push the results to connected WebSocket clients. This avoids constant polling from potentially hundreds of browser sessions all hitting the database directly.
-- Single notification per transaction, aggregating multiple row changes
CREATE OR REPLACE FUNCTION notify_order_summary_change()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'order_summary_refresh',
json_build_object(
'region', NEW.region,
'ts', extract(epoch from now())
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER order_summary_notify
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION notify_order_summary_change();The application server receives one notification per committed transaction, re-runs the dashboard aggregate query, and broadcasts the result over WebSockets. This decouples the database push from the number of connected clients.
Client Library Integration
Both the Python asyncpg and Node.js pg libraries have first-class support for LISTEN/NOTIFY. The pattern in both is the same: open a dedicated long-lived connection, register a callback or event handler, and issue the LISTEN command.
Python asyncpg
import asyncio
import asyncpg
import json
async def listen_for_orders():
conn = await asyncpg.connect('postgresql://user:pass@localhost/myapp')
async def handle_notification(conn, pid, channel, payload):
data = json.loads(payload)
print(f"Order update: {data}")
# Update cache, trigger webhook, push to WebSocket clients, etc.
await conn.add_listener('order_events', handle_notification)
print("Listening for order events...")
try:
await asyncio.sleep(float('inf')) # Keep connection alive
finally:
await conn.remove_listener('order_events', handle_notification)
await conn.close()
asyncio.run(listen_for_orders())The asyncpg callback receives four arguments: the connection object, the notifying backend's PID, the channel name, and the payload string. JSON deserialization happens in your handler. In a production system, wrap the connection in a reconnect loop — asyncpg connections drop on network interruptions, and the reconnect logic should also re-issue the add_listener calls.
Node.js pg
const { Client } = require('pg');
const client = new Client({
connectionString: 'postgresql://user:pass@localhost/myapp'
});
async function startListener() {
await client.connect();
client.on('notification', (msg) => {
const payload = JSON.parse(msg.payload);
console.log(`Channel: ${msg.channel}`, payload);
// Invalidate cache, push to WebSocket clients, etc.
});
await client.query('LISTEN order_events');
await client.query('LISTEN product_cache_invalidate');
console.log('Listening...');
}
// Handle connection drops — reconnect after 5 seconds
client.on('error', (err) => {
console.error('Listener connection error, reconnecting...');
setTimeout(startListener, 5000);
});
startListener();The Node.js pg client emits a notification event for every incoming notification message. You can LISTEN on multiple channels with a single connection. The error handler and reconnect pattern shown above is essential in production.
Limitations and When NOT to Use LISTEN/NOTIFY
LISTEN/NOTIFY is well-suited to a specific problem space. Outside that space, using it is a category error that will produce subtle failures. Know the constraints before committing to it.
Max payload: 8,000 bytes. This is a hard limit. Any NOTIFY call with a payload larger than 8,000 bytes will raise an error. The practical implication: never put a full serialized object in a notification. Put the primary key and the operation type. Fetch the rest from the database in the listener.
Not durable — missed events are gone. A listener that is restarting, deploying, or experiencing a network partition will miss every notification fired during that window. Any system where missing an event causes permanent data inconsistency is the wrong use case. If you need guaranteed delivery, use a transactional outbox pattern with a dedicated polling consumer, or a proper message queue.
No guaranteed delivery order across channels. Within a single listener connection, notifications arrive in the order the server dispatches them. Across channels or across connections, no ordering is guaranteed. Do not build finite state machines that require a specific notification sequence.
One NOTIFY per row by default. A trigger defined FOR EACH ROW fires once per affected row. A bulk UPDATE that touches 50,000 rows will generate 50,000 notifications. This will overwhelm your listeners and create significant overhead on the PostgreSQL server. For batch operations, use a statement-level trigger (FOR EACH STATEMENT) that sends a single aggregated notification.
Not suitable for high-frequency events. Above roughly 1,000 notifications per second sustained, LISTEN/NOTIFY will start to become a bottleneck. At that scale, Apache Kafka, Redis Streams, or NATS are the right tools.
LISTEN/NOTIFY vs. Debezium/CDC vs. pg_logical
Choosing between these three approaches depends on your durability and ordering requirements:
LISTEN/NOTIFY is the right choice when: you need a simple push signal with no durability requirement, your event rate is moderate (under ~500/sec), and you want zero additional infrastructure. Ideal for cache invalidation and job queue wake-ups.
Debezium (CDC via WAL) is the right choice when: you need every change captured with guaranteed delivery, you need a full audit log or event sourcing feed, or you are replicating data to downstream systems (data warehouses, search indexes, other databases). Debezium reads PostgreSQL's WAL through logical decoding and writes to Kafka, giving you durable, ordered, replayable change events. It adds Kafka and a Kafka Connect cluster to your stack but gives you guarantees LISTEN/NOTIFY cannot.
pg_logical / pgoutput is the right choice when: you are doing database-to-database replication — streaming changes from a source PostgreSQL instance to a replica or a different PostgreSQL cluster. This is the native PostgreSQL logical replication mechanism. It is not a general-purpose event bus.
Key Takeaways
- LISTEN/NOTIFY is a first-class PostgreSQL feature — no extra infrastructure, no new failure domain, works over your existing connection.
- Notifications are transactional: NOTIFY inside a transaction fires only on COMMIT, so you never get phantom events for rolled-back writes.
- Notifications are not durable: design your application to survive missed notifications by performing full state reads on reconnect.
- Keep payloads small — send a reference ID and operation type, not a full serialized object. Fetch the rest from the DB in the listener.
- Use a dedicated, unpooled long-lived connection for LISTEN subscribers. PgBouncer transaction pooling silently destroys LISTEN registrations.
- For guaranteed delivery and high throughput, use Debezium/CDC or a dedicated message broker. LISTEN/NOTIFY is a push signal, not a message queue.
Working with JusDB on PostgreSQL Real-Time Patterns
Implementing LISTEN/NOTIFY correctly in production involves more than getting the SQL right. The reconnect strategy for your listener processes, the decision to use FOR EACH ROW vs. FOR EACH STATEMENT triggers, the choice between polling fallback and pure push notification, and the interaction with your connection pooler all have material impact on reliability. Getting these wrong produces subtle failures that are difficult to reproduce in staging.
At JusDB, we help engineering teams design and implement real-time event patterns on PostgreSQL that are production-grade from day one. Whether you need a lightweight cache invalidation layer built on LISTEN/NOTIFY, a full CDC pipeline with Debezium and Kafka for guaranteed delivery, or an evaluation of which approach fits your specific workload, we bring hands-on production experience to the design and implementation.
If your team is building real-time features on top of PostgreSQL and wants to get the architecture right, reach out to JusDB. If you are looking for managed PostgreSQL advisory and operational support, explore our PostgreSQL services.