Postgres is enough: queues, search and pub/sub without five more containers
Before a two-developer project grows a Redis, a RabbitMQ and an Elasticsearch, look at what the database you already run can do. SKIP LOCKED queues, tsvector search, LISTEN/NOTIFY — and the measured signals that tell you when to graduate.
Somewhere in the first architecture sketch of almost every custom web application, the boxes multiply: Redis for caching, RabbitMQ for background jobs, Elasticsearch for search. Three more containers, three more things to monitor, three more failure modes — for an application with a few hundred users and a team of two.
Here's the thing those sketches skip: there's already a database in the diagram, and it can do all three jobs. Not as a hack — with first-class features that PostgreSQL has shipped and hardened for a decade. The same reasoning we apply to microservices applies to infrastructure: every box you add is a solution — make sure you have the problem first.
A job queue is a table with the right locking
The classic objection to database-backed queues was contention: ten workers polling the same table, fighting over row locks. FOR UPDATE SKIP LOCKED — in Postgres since 9.5 — ended that argument. Each worker claims the next unclaimed job and skips rows another worker already holds, no blocking, no double-processing:
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending' AND run_at <= now()
ORDER BY priority DESC, run_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;In Node.js you don't even write that SQL yourself — pg-boss wraps the pattern with retries, backoff, scheduling and dead-lettering, on the Postgres you already run.
The part that gets undersold is that a Postgres queue isn't just adequate — for one workload class it's strictly better than a broker: enqueueing is transactional. The order row and its "send confirmation e-mail" job commit together or not at all:
await db.transaction(async (tx) => {
const order = await tx.insert(orders).values(input).returning()
await boss.send("order-confirmation", { orderId: order.id }, { db: tx })
})With an external broker this is the dual-write problem: commit then publish, and a crash between the two silently loses the e-mail — or publishes for an order that rolled back. Solving that properly means an outbox table polled by a relay. An outbox table in Postgres. You were always going to have this table; the broker just adds a second hop after it.
It's no accident that the OCA queue_job module we deploy on most Odoo projects is Postgres-backed too. The pattern is boring, proven and everywhere.
Durable workflows: even Microsoft is betting this way
A queue covers "run this job later". The next complaint is multi-step: call the API, wait for the result, transform, fan out, aggregate — and survive a crash on step three without redoing steps one and two. That's the pitch of durable-execution engines like Temporal, and it's traditionally where even Postgres advocates concede a box.
Maybe not for much longer. Microsoft's pg_durable — in preview since this year — moves that workload into Postgres too: an extension whose background worker runs long-running, fault-tolerant workflows defined in plain SQL, checkpointing state after every step so a crash resumes instead of restarts. Their own positioning could have been this article's thesis statement: it's for teams that "already keep their state in Postgres and want to stop stitching together cron jobs, workers, queues, and status tables to make background work reliable." The feature list says the quiet part out loud: no Redis, no Temporal, no external services.
SELECT df.start(
'SELECT id FROM documents WHERE processed = false LIMIT 100' |=> 'batch'
~> 'UPDATE documents SET processed = true WHERE id IN (SELECT id FROM $batch.*)'
);Should you run it on a client project today? No — it's preview software, Postgres 17/18 only, and Microsoft itself flags the images as evaluation-only. What matters is the signal: one of the largest engineering organisations on the planet looked at the cron-plus-workers-plus-status-tables sprawl and concluded the fix is more Postgres, not more boxes. When a multi-step pipeline eventually outgrows your job table, check whether pg_durable has gone stable before you price in an orchestrator cluster.
Search: tsvector before Elasticsearch
"We need search" on a business application usually means: find the customer, the invoice, the product — a few hundred thousand rows, typo-tolerant, fast. That is not an Elasticsearch problem. A generated column and a GIN index cover it:
ALTER TABLE products ADD COLUMN search tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(description, '')), 'B')
) STORED;
CREATE INDEX products_search_idx ON products USING gin (search);
SELECT id, name, ts_rank(search, query) AS rank
FROM products, websearch_to_tsquery('simple', $1) AS query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;websearch_to_tsquery accepts what users actually type — quoted phrases, -exclusions — without you parsing anything. For typo tolerance, the pg_trgm extension adds trigram similarity (name % 'recieved' still finds "received") with its own GIN index. Weighted ranking, highlighting via ts_headline, multiple languages via per-column configurations: it's all in the box.
Where this stops being enough is real relevance engineering: faceted navigation over millions of SKUs, "did you mean", per-user ranking signals, search-as-you-type at scale. Our headless commerce storefronts sometimes cross that line — catalogue search is the product there, and a dedicated engine earns its keep. Your admin panel's customer lookup does not.
Pub/sub: LISTEN/NOTIFY, used honestly
Postgres ships a notification bus. Any connection can LISTEN invalidations, any transaction can NOTIFY invalidations, 'product:42', and delivery happens on commit — subscribers never hear about work that rolled back, which is exactly the property ad-hoc webhooks get wrong.
We use it for the small, real-time glue in business apps: invalidating an application cache when a row changes, waking the dashboard's SSE stream, poking a worker that new jobs exist so nothing polls on a timer.
Use it honestly, though — it's a doorbell, not a postal service:
- Notifications are not persisted. No subscriber listening at commit time means the message is gone. Treat the payload as "something changed, go look", and keep the truth in a table you can re-read on reconnect.
- Payloads cap at 8000 bytes. Send IDs, not documents.
- One LISTEN holds one connection. Fine for a handful of app instances; don't put it behind a transaction-mode pooler like PgBouncer, which breaks LISTEN semantics — give listeners a small pool of direct connections.
For a deploy with three app pods and a worker, that's everything event-driven architecture promised, minus the broker to babysit.
And caching?
Half the Redis instances we've inherited in takeover audits cache queries that Postgres would have answered from shared_buffers in a millisecond anyway — the cache was added before anyone measured. Start by not caching: correct indexes and a sensibly sized buffer pool make most "we need a cache" conversations disappear. When a specific expensive read genuinely needs materialising, an UNLOGGED table or a materialized view keeps it in SQL, transactionally close to the truth.
We're not cache absolutists — high-traffic storefronts are a different physics, and our headless commerce stack runs DragonflyDB for exactly that reason. But that decision followed load numbers, not the architecture diagram.
When you do graduate
The point is sequencing, not dogma. Postgres-as-everything is the right first architecture; some workloads outgrow it, and the signals are measurable:
- Queue throughput. Thousands of jobs per second, fan-out routing, cross-language consumers, dead-letter topology — that's broker territory. When heavy Odoo report pipelines hit that wall, we move them to RabbitMQ — the job table stays as the outbox.
- Search relevance as a product feature. When the business tunes ranking weekly, give it a real engine.
- Cache hit rates with load numbers behind them. Not "it might be slow later".
Each of those is a graduation you make from a working system, with data, moving one workload — not a bet you place on day one by adding three containers to an empty repo. Until the numbers show up, the answer to "what should we use for queues, search and events?" is the same as the answer to "what database should we use?" — and it's already running on your cluster.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.