RUBY ON RAILS · 16 MIN READ ·

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

Rails Postgres LISTEN/NOTIFY guide: real-time events with ActionCable, connection patterns, production caveats. When to use it instead of Redis or Kafka.

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

A founder pinged me last spring with a specific question: they had spun up a fifty-dollar-a-month Redis instance on Upstash to power exactly one feature — a “new comment” toast that popped up in the browser when someone else on the same document posted. The rest of the app was a normal Rails 8 monolith backed by Postgres on RDS. He wanted to know whether the Redis instance was worth the operational surface area for that one feature. It was not. The Postgres database he was already paying for could push those events itself, in under a millisecond, with a Ruby-flavoured API most Rails developers have never touched. This is the post I wish I had been able to send him: a proper walkthrough of Rails Postgres LISTEN/NOTIFY in production.

After nineteen years of Rails I have seen a lot of teams reach for a message broker the moment they need “something real-time.” Sometimes that is the right call. Often it is not — the volume is low, the ordering guarantees are weak enough that a database round-trip is fine, and the operational cost of another moving part exceeds the engineering cost of learning the tool you already own. Rails Postgres LISTEN/NOTIFY is the pattern for that middle ground, and the caveats are worth knowing before you ship.

What Rails Postgres LISTEN/NOTIFY Actually Is

Postgres has had a lightweight pub/sub mechanism since 2010. A session that runs LISTEN channel_name gets woken up whenever any other session on the same database runs NOTIFY channel_name, 'payload'. The payload is a plain string up to eight kilobytes, delivery is at-most-once inside the current transaction, and messages are dropped if no listener is connected. It is not Kafka. It is not RabbitMQ. It is a doorbell that fires while your webserver has a database connection open.

For Rails, that translates to one specific superpower: you can trigger a real-time UI update from any place that already writes to the database — a controller, a background job, a database trigger — without introducing a second broker. The subscriber side is a Ruby thread holding a raw PG::Connection in a wait loop, and the bridge into the rest of your app is whatever you want it to be: an ActionCable broadcast, a Rails.event.publish, or a plain method call.

The Trigger Side: NOTIFY From Rails

The write side is trivial. You can do it inline from a controller or an after_commit callback:

class Comment < ApplicationRecord
  after_commit :publish_created_event, on: :create

  private

  def publish_created_event
    payload = {
      id: id,
      document_id: document_id,
      author_id: author_id,
      created_at: created_at.iso8601
    }.to_json

    self.class.connection.execute(
      ActiveRecord::Base.sanitize_sql([
        "NOTIFY comments_created, ?", payload
      ])
    )
  end
end

Two things to notice. First, this fires only after the transaction commits, which is what you want — you do not want subscribers reading a row that a later ROLLBACK will delete. Second, sanitize_sql is not optional. NOTIFY takes a string literal, and if you interpolate a payload you built from user content without escaping single quotes, you have a SQL injection.

You can also skip Rails entirely and let Postgres itself notify on writes via a trigger. This is the pattern I recommend when the write path is not always a Rails one — think jobs written in Go, bulk imports through COPY, or a second app sharing the database:

CREATE OR REPLACE FUNCTION notify_comment_created() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify(
    'comments_created',
    json_build_object(
      'id', NEW.id,
      'document_id', NEW.document_id,
      'author_id', NEW.author_id,
      'created_at', NEW.created_at
    )::text
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER comments_notify_after_insert
AFTER INSERT ON comments
FOR EACH ROW EXECUTE FUNCTION notify_comment_created();

pg_notify is the function form of the NOTIFY command, and it is what you want inside PL/pgSQL because it takes the payload as a bind parameter. The trigger fires inside the transaction — subscribers still only see it once the transaction commits, because NOTIFY messages queue and flush at commit time.

The Listen Side: A Threaded Loop That Survives Production

The listener is where the real work is. You need a Ruby thread that holds a dedicated Postgres connection — not one from the ActiveRecord pool — and blocks on wait_for_notify. Here is the shape I ship:

# app/services/pg_notifier_listener.rb
class PgNotifierListener
  CHANNELS = %w[comments_created documents_updated].freeze

  def self.start!
    Thread.new { new.run }
  end

  def run
    loop do
      connect_and_listen
    rescue PG::ConnectionBad, PG::UnableToSend => e
      Rails.logger.warn("pg_listener: reconnecting after #{e.class}: #{e.message}")
      sleep 1
      retry
    end
  end

  private

  def connect_and_listen
    conn = raw_connection
    CHANNELS.each { |c| conn.exec("LISTEN #{c}") }

    loop do
      conn.wait_for_notify(30) do |channel, _pid, payload|
        dispatch(channel, payload)
      end
      # ping so the connection stays alive through NAT/firewall idle timeouts
      conn.exec("SELECT 1")
    end
  ensure
    conn&.close
  end

  def raw_connection
    config = ActiveRecord::Base.connection_db_config.configuration_hash
    PG::Connection.new(
      host:     config[:host],
      port:     config[:port],
      dbname:   config[:database],
      user:     config[:username],
      password: config[:password],
      sslmode:  config[:sslmode] || "prefer"
    )
  end

  def dispatch(channel, payload)
    parsed = JSON.parse(payload)
    ActiveSupport::Notifications.instrument("pg_notify.#{channel}", parsed)
    PgNotifierRouter.route(channel, parsed)
  rescue JSON::ParserError => e
    Rails.logger.error("pg_listener: bad payload on #{channel}: #{e.message}")
  end
end

A few production-hardening notes. The dedicated PG::Connection matters — you cannot borrow one from ActiveRecord::Base.connection_pool because a connection blocking in wait_for_notify cannot serve queries. The thirty-second wait_for_notify timeout with a SELECT 1 ping keeps the connection alive through cloud load balancers that will silently drop an idle TCP connection at five minutes. The ActiveSupport::Notifications.instrument call means your existing OpenTelemetry setup — see my post on OpenTelemetry in Rails — picks these up for free.

Wire the listener up from an initializer, but only in server processes:

# config/initializers/pg_notifier.rb
Rails.application.config.after_initialize do
  next unless defined?(Puma) || defined?(Rails::Server)
  next if Rails.env.test?

  PgNotifierListener.start!
end

You do not want a listener thread in every Sidekiq worker, in rails console, or in rake db:migrate. Guard on the process type.

The PgBouncer Problem You Have To Plan For

Here is the caveat that trips up every team that tries this in production: LISTEN/NOTIFY does not survive PgBouncer in transaction pooling mode. LISTEN binds the subscription to a specific backend connection; PgBouncer’s transaction pooler hands you a different backend on the next query, so your LISTEN silently stops receiving anything. I wrote about this at length in my post on PgBouncer transaction pooling and prepared statements — the same architectural constraint applies here.

There are three workable options. The first is to bypass PgBouncer for the listener connection only, connecting the listener thread directly to Postgres on port 5432 while the rest of the app uses the pooler on 6432. This is what I do most of the time — you accept one long-lived connection to the database in exchange for keeping your normal pool tight. The second is to run PgBouncer in session pooling mode, which preserves LISTEN but negates most of the connection multiplexing benefit. The third is to skip PgBouncer entirely and lean on Rails’ own connection pool.

Whatever you choose, document it in the runbook. Six months from now, someone will move the app behind a new pooler and wonder why the toasts stopped working.

Bridging Into ActionCable

The listener thread runs inside the same process as ActionCable, so the bridge is a single method call:

# app/services/pg_notifier_router.rb
class PgNotifierRouter
  def self.route(channel, payload)
    case channel
    when "comments_created"
      DocumentChannel.broadcast_to(
        Document.find(payload["document_id"]),
        type: "comment_created",
        comment: payload
      )
    when "documents_updated"
      DocumentChannel.broadcast_to(
        Document.find(payload["id"]),
        type: "document_updated",
        document: payload
      )
    end
  end
end

This is where the pattern earns its keep. You did not need Redis, you did not need Solid Cable, and the round-trip from INSERT to browser toast is a couple of milliseconds. If you have already moved to Solid Cable for the transport, this composes cleanly — the LISTEN/NOTIFY channel triggers the broadcast, and Solid Cable handles delivery across web processes.

Payload Size, Ordering, and Delivery Guarantees

Postgres caps the NOTIFY payload at eight kilobytes — this is a hard limit, and any attempt to send more raises an error. In practice you should treat the payload as a pointer, not a document. Send the record id and a few fields the subscriber needs to decide whether to care; if the subscriber needs the full row, it queries for it.

def publish_created_event
  payload = { id: id, document_id: document_id }.to_json
  self.class.connection.execute(
    ActiveRecord::Base.sanitize_sql(["NOTIFY comments_created, ?", payload])
  )
end

Delivery is at-most-once. If the listener thread is not connected — because the process is restarting, because the network flapped, because you deployed — messages fired during that window are gone. This is fine for UI notifications, presence indicators, and cache invalidations. It is not fine for anything that must reconcile — order fulfillment, billing events, audit trails. For those, use a durable queue like Solid Queue and treat NOTIFY as a low-latency nudge, with a durable job as the backstop.

Ordering is per-channel, per-transaction-commit-order. Two notifications published in the same transaction arrive together, in the order they were queued. Notifications from two concurrent transactions are ordered by commit time, which is what you almost always want.

Debouncing and Deduplication

A common pattern is a document that gets ten updates in a second — you want the browser to see one refresh, not ten. Debounce on the subscriber side rather than trying to reduce writes. The listener is a plain Ruby thread; a small in-memory coalescing map is fine:

class DebouncedRouter
  DEBOUNCE_MS = 200

  def initialize
    @pending = {}
    @mutex = Mutex.new
    start_flusher
  end

  def route(channel, payload)
    key = "#{channel}:#{payload['id']}"
    @mutex.synchronize { @pending[key] = [channel, payload, monotonic_ms] }
  end

  private

  def start_flusher
    Thread.new do
      loop do
        sleep 0.05
        flush
      end
    end
  end

  def flush
    now = monotonic_ms
    ready = @mutex.synchronize do
      due, kept = @pending.partition { |_, (_c, _p, t)| now - t >= DEBOUNCE_MS }
      @pending = kept.to_h
      due
    end
    ready.each { |_key, (channel, payload, _)| PgNotifierRouter.route(channel, payload) }
  end

  def monotonic_ms
    (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
  end
end

This deliberately drops intermediate updates for the same id — the last write wins after 200ms of quiet. If you need every update to survive, use a durable queue instead.

When To Skip LISTEN/NOTIFY And Reach For Redis Or Kafka

LISTEN/NOTIFY is the right tool when the events are ephemeral, the volume is under a few hundred per second, subscribers can tolerate missed messages during restarts, and you would rather not add another piece of infrastructure. If you need durable delivery across restarts, ordered fan-out to hundreds of workers, replay of the last hour of events, or cross-region distribution, you are outside its comfort zone. Redis Streams is a natural next step; Kafka lives one tier further out.

The other case where I skip it is high write volume against tables that already have contention. Every NOTIFY acquires a lightweight lock on a Postgres-internal queue; at several thousand per second you can see it in pg_stat_activity. In practice I have never hit that ceiling in a Rails app, but if you are running an event bus for a fleet of microservices, it is real.

FAQ

Does Postgres LISTEN/NOTIFY work with connection pooling?

Not with transaction pooling. The LISTEN binds to a specific backend connection, and a transaction pooler like PgBouncer in transaction mode will hand out a different backend on the next query, so the subscription silently stops receiving. Either connect the listener directly to Postgres (bypassing the pooler) or run the pooler in session mode.

How large can a NOTIFY payload be?

Eight kilobytes. This is a hard Postgres limit. Treat the payload as a pointer — send the id and a few fields the subscriber needs to filter on, and have the subscriber query the full row if it needs more.

Is LISTEN/NOTIFY a replacement for ActionCable?

No. LISTEN/NOTIFY is server-to-server, inside your Rails process. ActionCable is server-to-browser, over WebSockets. You use LISTEN/NOTIFY to fan an event out to every Rails process, and then ActionCable to push it to the connected browsers. On Rails 8 with Solid Cable, the two compose cleanly — Postgres is doing the pub/sub either way.

What happens to notifications during a deploy?

They are dropped. LISTEN/NOTIFY is at-most-once; any notification fired while the listener process is restarting is gone. This is fine for UI toasts and cache invalidation. For anything that must not be lost — orders, payments, audit events — use a durable queue like Solid Queue, and treat NOTIFY as the low-latency nudge on top.


Need help deciding whether your Rails app can skip Redis for real-time features, or want a review of your Postgres and ActionCable topology? TTB Software has been shipping production Rails since 2007 and works with founders and engineering teams as a fractional CTO. Nineteen years in, the boring answers are still usually the right ones.

#rails-postgres-listen-notify #postgres-pub-sub-rails #rails-real-time-events #actioncable-postgres #rails-database-triggers #postgres-notify-rails

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software