Event-Driven Architecture With Just Postgres (Until You Really Can't)
Backend·By Islam Gamal··27 min read

Event-Driven Architecture With Just Postgres (Until You Really Can't)

Outbox pattern, LISTEN/NOTIFY, logical replication, and pg_cron — the surprising amount of event-driven system you can run on one Postgres before reaching for Kafka.

Islam Gamal
Islam Gamal
AI · Data · Automation Engineer
TL;DR

Most teams introduce Kafka or SQS a year too early and spend that year fighting infrastructure instead of shipping features. Postgres plus the outbox pattern, LISTEN/NOTIFY, and a background worker will carry you to millions of events per day with a fraction of the operational surface.

Key takeaways
  • The outbox pattern gives you exactly-once, transactionally safe event publishing without a message broker.
  • LISTEN/NOTIFY is a real-time push channel that avoids polling for latencies down to ~50ms.
  • Logical replication turns Postgres into an event source for downstream consumers.
  • pg_cron schedules recurring jobs without adding a separate scheduler service.
  • Adopt Kafka when you have a real reason (fan-out to 5+ consumers, cross-region replication, log retention needs) — not before.

The premature-Kafka trap

Adding a message broker changes your operational surface completely: a new datastore to monitor, back up, upgrade, and secure; new failure modes (topic misconfig, consumer lag, poison messages); new libraries and idioms in every service. It is worth it — but only after you've outgrown the alternatives.

Nearly every event-driven system I've built in the last decade shipped v1 on Postgres and only migrated pieces to Kafka once a concrete constraint showed up. This section is the pattern for v1.

The outbox pattern in Postgres

You want to update a row and publish an event about it. The naïve approach — write to the DB, then publish to a broker — has a race: the DB commits, the process crashes, the event is never sent. The outbox pattern makes both writes part of the same transaction.

create table outbox (
  id           bigserial primary key,
  aggregate    text not null,
  aggregate_id uuid not null,
  event_type   text not null,
  payload      jsonb not null,
  created_at   timestamptz not null default now(),
  processed_at timestamptz
);
create index outbox_pending on outbox(id) where processed_at is null;

Every domain write inserts a row into outbox in the same transaction. A worker polls where processed_at is null order by id limit 100, publishes each event to the destination (webhook, another table, an external API), and marks it processed. Because both writes are in one transaction, you never lose an event to a crash between them.

Pro tip: Use SKIP LOCKED so multiple workers can drain the outbox in parallel: select ... for update skip locked.

LISTEN/NOTIFY for push instead of poll

Polling every second is fine at low volume but wastes CPU and adds latency. Postgres has a built-in pub/sub channel:

-- publisher (in the same transaction as the insert)
insert into outbox (...) values (...);
notify outbox_channel, 'new';

-- consumer
listen outbox_channel;
-- your driver will deliver a notification message; then drain the outbox

LISTEN/NOTIFY is fire-and-forget — if no consumer is listening at the moment of NOTIFY, the notification is dropped. That's fine because your consumer's real source of truth is the outbox table; NOTIFY is just a hint that says 'wake up and drain now'. This pattern reliably gets you 50-200ms end-to-end latency.

Logical replication for external consumers

When another team or system needs a live stream of Postgres changes, logical replication turns Postgres into an event source without your app writing any extra code. Tools like Debezium, pg_kafka_connect, or a homegrown consumer of pg_logical can stream every insert/update/delete as a structured event.

This is the pattern I recommend when a downstream team says 'I need to react to every order change'. You don't build an integration for them — you give them a replication slot and they consume the WAL. Zero coupling in your app code.

pg_cron for scheduled jobs

Every serious system needs recurring jobs: retention cleanup, aggregation, reminders, health checks. Instead of running a separate scheduler service, install pg_cron and put the schedule next to the SQL it runs.

select cron.schedule('cleanup-old-events', '0 3 * * *', $$
  delete from outbox where processed_at < now() - interval '30 days'
$$);

select cron.schedule('daily-rollup', '10 0 * * *', $$
  insert into daily_metrics select ... from events where day = current_date - 1
$$);

Schedules live in the database, versioned via migrations. Failures are visible in cron.job_run_details. One less service to run.

When to graduate to a real broker

Postgres event-driven stops being the right answer when you hit one of these:

  • You need fan-out to 5+ independent consumers with different processing speeds.
  • You need durable log retention past a few days (for reprocessing, ML training, replay).
  • You need >10k events/sec sustained per topic — the outbox worker becomes the bottleneck.
  • You need multi-region replication with per-region consumers.
  • You need ordering guarantees across many partitions.

Even then, migrate one topic at a time. The outbox pattern doesn't disappear — it becomes the source that feeds the broker via a Kafka Connect Debezium sink. Nothing you built is wasted.

#Postgres#Event-Driven#Outbox#Architecture#Backend

Frequently asked

Is LISTEN/NOTIFY reliable?

No — treat it as a hint, not a delivery mechanism. Your consumer should always drain a persistent table (the outbox). NOTIFY just tells it when to wake up.

How many events per day can this scale to?

In practice, 10-50M/day per Postgres instance with a well-tuned outbox and a couple of worker processes. Above that, start moving individual topics to a real broker.

Should I use pg_cron in production?

Yes, for scheduling logic that belongs to the database. Long-running compute belongs in an app worker, not inside pg_cron.

Building something similar?

I help teams ship production-grade AI agents, n8n workflows, and data platforms. Let's talk about what you're building.

Work with me
Islam Gamal
Written by

Islam Gamal

AI, Data & Automation Engineer. I design and ship production AI agents, n8n workflows, and cloud data platforms — with a focus on reliability, cost, and measurable business impact. Founder of Tashghil and Tek bil Arabi.

More from Islam Gamal

Browse every article by Islam Gamal on the author page.