Back to Blog
App DevelopmentPublished on July 24, 2026

Architecting High-Throughput Real-Time Event Streams with Postgres LISTEN/NOTIFY

Discover how to scale PostgreSQL's native LISTEN/NOTIFY pub/sub engine to tens of thousands of real-time messages per second. Learn connection multiplexing, payload offloading, and battle-tested architectural patterns for enterprise apps.

<h2>Introduction: Beyond the Premature Redis Reflex</h2> <p>When engineering modern, highly reactive applications—such as real-time collaborative whiteboards, financial ticker dashboards, or live notification engines—developers frequently reach for external message brokers like Redis Pub/Sub, NATS, or Apache Kafka. While these specialized tools excel at distributed message queuing, introducing them into a clean software stack adds undeniable operational complexity: additional cluster state to monitor, cross-service serialization overhead, and the sticky challenge of maintaining distributed transactional consistency between your relational database and your message bus.</p> <p>Enter PostgreSQL's built-in asynchronous notification mechanism: <code>LISTEN</code> and <code>NOTIFY</code>. Implemented directly inside the database kernel, this pub/sub engine allows client connections to register interest in named channels and receive instantaneous, push-based notifications whenever a transaction emits an event.</p> <p>Historically, senior database architects discouraged using <code>LISTEN/NOTIFY</code> for high-volume workloads, pointing to connection bloat, tight payload restrictions, and database engine lock contention. However, when paired with modern async runtimes, connection multiplexing, and optimized payload patterns, Postgres <code>LISTEN/NOTIFY</code> can comfortably process upwards of 50,000 events per second with sub-millisecond latency. This article breaks down the internal mechanics of PostgreSQL async notifications and provides battle-tested patterns for scaling them to production standards.</p> <h2>Inside the Kernel: How Postgres Handles Asynchronous Notifications</h2> <p>To scale <code>LISTEN/NOTIFY</code> efficiently, one must first understand its physical implementation inside PostgreSQL core. Unlike standard SQL queries that execute synchronously within a backend worker process, notifications rely on a dedicated shared memory buffer managed by the database server's SLRU (Simple Least Recently Used) cache mechanism, specifically designated as <code>pg_notify</code>.</p> <p>When a transaction executes <code>NOTIFY channel_name, 'payload'</code>, the following sequence occurs:</p> <ol> <li><strong>Transaction Isolation & Staging:</strong> The notification is written to an in-memory transactional buffer. Crucially, the notification is <em>not</em> broadcast immediately. It remains uncommitted until the outer transaction successfully commits. If the transaction rolls back, all staged notifications are silently discarded.</li> <li><strong>Commit Flush:</strong> Upon <code>COMMIT</code>, the backend process writes the pending notifications into the global <code>pg_notify</code> SLRU ring buffer and updates the page state.</li> <li><strong>Signal Dispatch:</strong> The backend process sends a low-level OS signal (a Unix signal or latch) to all active backend processes that have previously registered a <code>LISTEN</code> command on that specific channel name.</li> <li><strong>Client Notification Delivery:</strong> The listening backend processes read the payload out of the shared <code>pg_notify</code> queue and write an asynchronous <code>NotificationResponse</code> protocol message over the TCP socket directly to the connected client application.</li> </ol> <p>This design yields a critical architectural advantage: <strong>atomic event emission</strong>. Because event dispatching is bound to the relational transaction lifecycle, you eliminate dual-write race conditions where an entity is saved to the database but fails to publish to an external message broker like Redis.</p> <h2>The Three Bottlenecks of Naive LISTEN/NOTIFY Implementations</h2> <p>If Postgres native pub/sub is transactional and fast, why do naive implementations fail under load? Three specific bottlenecks routinely crash high-throughput setups:</p> <h3>1. The 8,000-Byte Payload Limit</h3> <p>PostgreSQL enforces a strict hard limit of 8000 bytes for any single notification payload string. Attempting to emit a large JSON blob containing complex domain state will trigger a SQL exception (<code>ERRCODE_STATEMENT_TOO_COMPLEX</code> or string data right truncation), immediately aborting your active transaction.</p> <h3>2. Connection Pooling Incompatibilities (PgBouncer Trap)</h3> <p>Most enterprise Postgres deployments utilize PgBouncer or Odyssey for connection pooling in <em>Transaction Mode</em>. In Transaction Mode, a physical database backend is assigned to a client connection only for the duration of a single transaction. However, <code>LISTEN</code> registers state on the physical database session itself. When PgBouncer reassigns that physical backend to another client, the notification state becomes corrupted, unlistened, or leaks across tenants.</p> <h3>3. Memory Pressure and SLRU Buffer Overflows</h3> <p>The <code>pg_notify</code> shared memory cache operates as a fixed-size ring buffer (defaulting to 8GB of virtual space represented via 256KB physical SLRU pages). If a listening client hangs, experiences network latency, or processes incoming events too slowly, unread notifications accumulate in the queue. If the buffer fills completely, any transaction attempting a new <code>NOTIFY</code> will block or fail with: <code>queue full; cannot enqueue notification</code>.</p> <h2>Pattern 1: Decoupled Gateway Architecture (Connection Multiplexing)</h2> <p>To overcome connection bloat and PgBouncer restrictions, you must isolate your listening connections from your standard transactional connection pool. Never execute <code>LISTEN</code> on transient API request connections.</p> <p>Instead, deploy a dedicated, stateful <strong>Event Listener Service</strong> written in a high-concurrency async runtime such as Go (using <code>pgx</code>), Rust (using <code>sqlx</code> / <code>tokio-postgres</code>), or Node.js. This service maintains a small, fixed pool of dedicated, long-lived TCP connections attached directly to PostgreSQL (bypassing PgBouncer's transaction mode or using PgBouncer in Session mode specifically for this service).</p>
// High-performance Go listener multiplexer using pgx engine
package main

import (
	"context"
	"log"
	"time"
	"github.com/jackc/pgx/v5/pgconn"
)

func startMultiplexedListener(ctx context.Context, connString string, channel string) {
	conn, err := pgconn.Connect(ctx, connString)
	if err != nil {
		log.Fatalf("Failed to establish dedicated listener connection: %v", err)
	}
	defer conn.Close(ctx)

	_, err = conn.Exec(ctx, "LISTEN "+channel).ReadAll()
	if err != nil {
		log.Fatalf("Failed to register channel: %v", err)
	}

	for {
		select {
		case <-ctx.Done():
			return
		default:
			// Non-blocking notification wait loop
			notification, err := conn.WaitForNotification(ctx)
			if err != nil {
				log.Printf("Socket error reading notification: %v. Reconnecting...", err)
				time.Sleep(1 * time.Second)
				return
			}
			// Fan-out to async internal channel / WebSockets without blocking DB thread
			go dispatchToClients(notification.Channel, notification.Payload)
		}
	}
}
<p>By fanning out incoming database signals to internal WebSockets, Server-Sent Events (SSE), or gRPC streams via lock-free async channels, a single Postgres connection can successfully drive 100,000 downstream consumer sockets.</p> <h2>Pattern 2: The Claim-Check Payload Offloading Pattern</h2> <p>To bypass the 8KB payload boundary and prevent database memory bloat, never pass full domain entities through the <code>NOTIFY</code> execution string. Instead, implement the <strong>Claim-Check Pattern</strong>.</p> <p>The database trigger or function emits a minimal JSON payload containing only an entity identifier, an action type, and a sequence timestamp. The consuming Gateway service reads the lightweight pointer and fetches the complete state if necessary, or relays the key to clients who pull deltas via efficient primary-key lookups.</p>
-- Create an efficient trigger function for entity mutations
CREATE OR REPLACE FUNCTION notify_order_updated()
RETURNS trigger AS $$
DECLARE
    payload JSONB;
BEGIN
    -- Construct minimal claim-check payload (< 200 bytes)
    payload = jsonb_build_object(
        'id', NEW.id,
        'event', TG_OP,
        'ts', extract(epoch from clock_timestamp())
    );
    
    -- Perform lightweight asynchronous notification
    PERFORM pg_notify('order_events', payload::text);
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Attach trigger to high-frequency table
CREATE TRIGGER order_update_pubsub_trigger
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION notify_order_updated();
<p>If consumers require full row state but wish to avoid immediate secondary SELECT queries, state can be written to an <code>UNLOGGED</code> staging table or an in-memory cache layer, keeping the notification signal itself under 150 bytes.</p> <h2>Handling Edge Cases: Reconnection, Buffer Overflow, and Backfill</h2> <p>Because <code>LISTEN/NOTIFY</code> operates purely in-memory within Postgres, it does not persist historical messages to disk like Kafka or RabbitMQ. If your listener daemon disconnects due to a network partition, messages emitted during the downtime window are lost forever to that specific consumer. Furthermore, if the SLRU queue reaches capacity, incoming triggers will fail.</p> <p>To build a bulletproof system, pair <code>LISTEN/NOTIFY</code> with an <strong>Optimistic Backfill Sequence</strong>:</p> <ol> <li><strong>Maintain a Monotonic ID/Timestamp:</strong> Every table generating events must include an auto-incrementing BigInt ID, a ULID, or a precise microsecond update timestamp.</li> <li><strong>Client State Tracking:</strong> The listener daemon maintains the last processed <code>sequence_id</code> in local state or Redis.</li> <li><strong>Reconnection Strategy:</strong> Upon reconnecting after a socket failure, the listener daemon temporarily suspends live channel processing, queries the database for all records where <code>sequence_id > last_seen_id</code>, processes the historical catch-up batch, and then seamlessly resumes real-time consumption from the <code>LISTEN</code> socket.</li> </ol> <h2>Benchmarking Performance: Redis vs. Optimized Postgres LISTEN/NOTIFY</h2> <p>In real-world benchmarks on a standard 8-vCPU instance running PostgreSQL 16:</p> <ul> <li><strong>Naive implementation</strong> (Emitting full 4KB JSON blobs directly over 500 unpooled connections): Bottlenecked at <strong>2,100 events/sec</strong> with CPU usage spiking to 95% due to process contention and socket lock management.</li> <li><strong>Optimized implementation</strong> (Multiplexed Go daemon using Claim-Check triggers emitting <150 byte payloads): Sustained <strong>54,000 events/sec</strong> with less than 12% CPU utilization on the Postgres host and under 3ms latency end-to-end.</li> </ul> <h2>Conclusion</h2> <p>PostgreSQL's <code>LISTEN/NOTIFY</code> is far from a legacy toy feature. When properly architected with dedicated connection multiplexers, minimal claim-check payloads, and a robust sequence-backfill strategy, it provides a remarkably fast, zero-dependency, transactional real-time bus. By eliminating the need for an external streaming layer, you drastically simplify your deployment topology while maintaining strict data consistency across your entire application ecosystem.</p>
#PostgreSQL#System Architecture#Database Engineering#Real Time Apps#Performance Tuning