April 12, 2026 8 min read

PostgreSQL LISTEN/NOTIFY for Real-Time Payment Events — When You Don't Need Kafka

How we replaced a Kafka cluster with PostgreSQL LISTEN/NOTIFY for real-time payment event delivery, and the trade-offs we discovered along the way.

The Kafka Tax

Last year, our team needed real-time payment event delivery. A payment would land in our database, and downstream consumers — a webhook dispatcher, a dashboard, an analytics pipeline — needed to know about it within seconds. The obvious answer in 2025 was Kafka. So we stood up a three-broker cluster, configured topics and partitions, wrote producers and consumers, and deployed the whole thing behind a managed service.

It worked. But the operational cost was absurd for what we were actually doing. We were processing around 120 payment events per second at peak. Not 120,000. Not 12,000. A hundred and twenty. For that volume, we were maintaining ZooKeeper (yes, we hadn't migrated to KRaft yet), monitoring broker lag, tuning consumer group rebalancing, and paying for three always-on instances that sat at 2% CPU most of the day.

Meanwhile, we already had a PostgreSQL cluster running our payment data. The events we wanted to broadcast were literally rows being inserted into a payment_events table. The source of truth and the message broker were two different systems for no good reason. So we asked the question that should have been asked first: can Postgres itself tell us when a row gets inserted?

It can. And for our scale, it was more than enough.

How LISTEN/NOTIFY Works

PostgreSQL has a built-in pub/sub mechanism that most people never touch. It's simple: a session calls LISTEN channel_name to subscribe, and any other session can call NOTIFY channel_name, 'payload' to broadcast a message to all listeners on that channel. The notification is delivered asynchronously over the existing PostgreSQL connection — no additional ports, no separate protocol, no new infrastructure.

The key difference from polling is that the client doesn't ask for updates. The database pushes them. Your application holds an open connection, and PostgreSQL sends a notification the moment NOTIFY fires. In practice, the latency from INSERT to notification receipt is under 5ms on the same network. Compare that to a polling loop that checks every 500ms and you're looking at a 100x improvement in responsiveness with less database load.

Notifications are transactional, too. If you call NOTIFY inside a transaction that rolls back, the notification is never sent. This is a subtle but important property for payment systems — you'll never notify consumers about a payment that didn't actually commit.

Architecture: Payment Events Without a Message Broker

The pattern we landed on removes the message broker entirely. A trigger on the payment_events table fires NOTIFY with the event ID whenever a new row is inserted. Our Go service holds a persistent connection to PostgreSQL with LISTEN active, receives the notification, fetches the full event payload, and fans it out to WebSocket clients and webhook dispatchers.

Event Flow Architecture
Database
PostgreSQL
INSERT Trigger
NOTIFY
Go Service
Event Listener
pgx / LISTEN
Fan-out
WebSocket
Dashboard
Webhooks
Merchants
Analytics
Pipeline

The trigger itself is minimal. It doesn't serialize the entire row into the notification payload — that would hit the 8KB limit fast. Instead, it sends just the event ID and type:

CREATE OR REPLACE FUNCTION notify_payment_event()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify(
    'payment_events',
    json_build_object(
      'id', NEW.id,
      'type', NEW.event_type,
      'merchant_id', NEW.merchant_id
    )::text
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER payment_event_notify
  AFTER INSERT ON payment_events
  FOR EACH ROW EXECUTE FUNCTION notify_payment_event();

The listener receives the notification, parses the ID, and fetches the full event from the database if the consumer needs the complete payload. This two-step approach — notify with a reference, fetch on demand — sidesteps the payload size limit entirely and keeps the trigger lightweight.

Go Implementation

We use pgx for the listener because it has first-class support for PostgreSQL's LISTEN/NOTIFY protocol. The standard database/sql package doesn't expose the notification channel, so pgx is essentially required here. The core listener loop looks like this:

func (s *EventListener) Start(ctx context.Context) error {
    conn, err := pgx.Connect(ctx, s.databaseURL)
    if err != nil {
        return fmt.Errorf("connect: %w", err)
    }
    defer conn.Close(ctx)

    _, err = conn.Exec(ctx, "LISTEN payment_events")
    if err != nil {
        return fmt.Errorf("listen: %w", err)
    }

    log.Println("listening on channel: payment_events")

    for {
        notification, err := conn.WaitForNotification(ctx)
        if err != nil {
            if ctx.Err() != nil {
                return nil // clean shutdown
            }
            return fmt.Errorf("wait: %w", err)
        }

        var event PaymentNotification
        if err := json.Unmarshal(
            []byte(notification.Payload), &event,
        ); err != nil {
            log.Printf("bad payload: %v", err)
            continue
        }

        s.dispatch(ctx, event)
    }
}

A few things worth noting. The WaitForNotification call blocks until a notification arrives or the context is cancelled. This is not polling — the pgx driver uses the PostgreSQL wire protocol's async notification mechanism, so there's zero wasted work between events. The connection used for LISTEN is dedicated; you can't share it with your regular query pool because LISTEN requires a persistent session.

Reconnection Logic

The listener connection will drop. Networks blip, PostgreSQL restarts during maintenance, load balancers time out idle connections. You need a reconnection wrapper around the listener:

func (s *EventListener) RunForever(ctx context.Context) {
    backoff := time.Second

    for {
        err := s.Start(ctx)
        if ctx.Err() != nil {
            return
        }

        log.Printf("listener disconnected: %v", err)
        log.Printf("reconnecting in %v", backoff)

        select {
        case <-time.After(backoff):
            backoff = min(backoff*2, 30*time.Second)
        case <-ctx.Done():
            return
        }
    }
}

func min(a, b time.Duration) time.Duration {
    if a < b {
        return a
    }
    return b
}

Exponential backoff caps at 30 seconds. On successful reconnection, we reset the backoff. Simple, but it's kept our listener running through every Postgres maintenance window we've had.

The 8KB Payload Limit

Practical tip: PostgreSQL NOTIFY payloads are limited to 8,000 bytes. Don't try to serialize your entire payment event into the notification. Send the event ID and type, then have the consumer fetch the full record from the database. This keeps your triggers fast and avoids silent truncation or outright errors on large payloads.

This is the single biggest gotcha with LISTEN/NOTIFY. The pg_notify function accepts a text payload, but it's capped at roughly 8KB. If your payload exceeds that, PostgreSQL throws an error and the notification is never sent. For small JSON blobs — an ID, a type, a timestamp — you'll never hit it. But if someone decides to stuff the full payment object with line items, metadata, and card details into the notification, it'll break silently in production when a merchant sends a particularly large order.

Our rule is simple: notifications carry references, not data. The trigger sends {"id": "evt_abc123", "type": "payment.captured", "merchant_id": "mch_xyz"} and the consumer does a SELECT to get the rest. The extra round-trip adds maybe 2ms of latency, which is invisible in practice.

Reconnection and Missed Events

Warning: LISTEN/NOTIFY has no built-in replay. If your listener is disconnected for 30 seconds, every notification sent during that window is gone. Unlike Kafka, there's no offset to rewind to. You must build your own catch-up mechanism or accept the gap.

This is the trade-off that matters most. Kafka retains messages for days. PostgreSQL NOTIFY is fire-and-forget — if nobody is listening when the notification fires, it vanishes. For a payment system, that's not acceptable. We can't just shrug and miss a payment.failed event because the listener was restarting.

Our solution is a sequence cursor. The payment_events table has an auto-incrementing sequence_id column. The listener tracks the last sequence ID it successfully processed. On reconnection, before issuing LISTEN, it runs a catch-up query:

SELECT id, event_type, merchant_id, payload, sequence_id
FROM payment_events
WHERE sequence_id > $1
ORDER BY sequence_id ASC
LIMIT 1000

This fills the gap. Any events that arrived while the listener was down get replayed in order. Once the catch-up is complete, the listener switches to the real-time LISTEN path. There's a brief window where you might process a duplicate — an event that arrived via both the catch-up query and a notification — so your consumers need to be idempotent. But that's a requirement you should have anyway in payment systems.

When This Breaks Down

I'd be dishonest if I said LISTEN/NOTIFY is a universal replacement for Kafka. It's not. Here's where the approach stops working:

Dimension PostgreSQL LISTEN/NOTIFY Kafka
Throughput Hundreds/sec comfortably Millions/sec with partitioning
Latency <5ms on same network 10-50ms typical
Replay / Retention None (fire-and-forget) Days/weeks of retention
Multi-consumer All listeners get all messages Consumer groups with offsets
Cross-service Requires shared DB access Network-native, any language
Operational cost Zero — it's your existing DB Brokers, ZooKeeper/KRaft, monitoring
Ordering Single channel, total order Per-partition ordering

If you need multiple independent consumer groups that each track their own offset, Kafka is the right tool. If you're broadcasting events across services owned by different teams, coupling them to your PostgreSQL instance is a bad idea. If your event volume is in the thousands per second and climbing, you'll start putting meaningful load on your primary database connection just for notifications.

The sweet spot for LISTEN/NOTIFY is a single service (or a small cluster of instances of the same service) that needs to react to database changes in real time, at moderate volume, without adding infrastructure. That described our payment notification system exactly.

Production Numbers

We've been running this in production for seven months. Here's where things stand:

~120
Events/sec
peak throughput
3.2ms
Median latency
NOTIFY to Go handler
99.99%
Delivery rate
with catch-up replay
0
New infra components
just PostgreSQL + Go

The median latency of 3.2ms is measured from the moment pg_notify fires inside the trigger to the moment our Go handler's WaitForNotification returns. End-to-end — from the API request that inserts the payment event to the webhook hitting the merchant's server — we see about 45ms at p95. Most of that is the outbound HTTP call, not the notification path.

We've had exactly four listener disconnections in seven months, all during planned Postgres maintenance. The catch-up mechanism replayed a total of 847 events across those four windows. Zero events lost.

The Kafka cluster we decommissioned was costing us roughly $1,200/month in compute alone, plus the engineering time to keep it healthy. That line item is gone now.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. The approach described here worked for our specific scale and requirements — always evaluate trade-offs against your own workload before removing infrastructure components.