There is a recurring cycle in database architecture. A team starts with a single relational database for everything, hits a performance ceiling, and immediately splits their workload across four different specialized data stores. Two years later, they spend fifty percent of their engineering capacity maintaining sync pipelines, debugging distributed lock queues, and fighting inconsistent backups.
We do not need to build our systems this way anymore. Modern PostgreSQL contains built-in features that can successfully replace Redis, RabbitMQ, and MongoDB for a broad class of workloads. However, consolidating your stack is not a magic solution. The choice to run everything on Postgres should be driven by hard numbers, clear operational trade-offs, and defined workload boundaries.
I also recorded a companion video walking through these tradeoffs with real demos—you can watch it here if you’d rather listen. But if you’re the type who wants to see actual DDL, worker queries, and the math before you buy into the “just use Postgres” hype, let’s dig in.
TL;DR – The decision framework before you rip anything out
Start with this checklist. If you answer yes to all four, consolidating on Postgres is a solid move today. If you hesitate on even one, keep the specialist system for that workload and don’t feel bad about it.
- Throughput & dataset thresholds — Under 10,000 requests per second and less than about a terabyte of hot data per workload. Beyond that, a single Postgres instance starts to struggle, and the operational pain of a distributed cache or queue is worth it.
-
The JOIN test — Does your cached data, queued messages, or document body carry foreign keys that relate to your core relational rows? If you find yourself writing code to correlate a Redis key back to a
userstable, you’re already doing a bad join—let the database do it. - Team size & expertise — Can your team of five people realistically run four different databases well? Think about on-call rotation: one person needs to understand Redis persistence, RabbitMQ clustering, MongoDB sharding, and Postgres vacuum. That’s too many footguns. One engine slashes cognitive load.
-
Transactional consistency is a real requirement — When charging a credit card and queuing a receipt email must either both happen or both not happen, Postgres gives you that atomic
COMMIT. With separate systems, you’re writing compensating transactions and retry logic that eventually have a bug you’ll find at 2 a.m.
Honest failure modes: Consolidation isn’t free—you’ll hit vacuum pressure, connection exhaustion, and single-writer contention. We cover exactly where it breaks later.
Operational Overhead of the Multi-Engine Stack
Every database you add to your infrastructure introduces a long-term maintenance cost. If your application uses Postgres for your primary data, Redis for caching, MongoDB for JSON documents, and RabbitMQ for message queuing, you are managing four completely different systems.
This architecture requires:
- Four separate connection pools with distinct configuration rules.
- Four different security patch lifecycles and network access controls.
- Four distinct backup strategies and recovery protocols.
- Custom glue code to ensure that when a write fails in MongoDB, the associated cache key in Redis is evicted and the message in RabbitMQ is rolled back.
This last point is where applications frequently break. In a distributed infrastructure, executing a business action like charging a credit card and queuing a receipt email looks like this:
1. Charge card (Third-party API call)
2. Save transaction to Postgres (Commit)
3. Push "Send Email" task to RabbitMQ (Enqueue)
4. Cache transaction details in Redis (Set)
If the RabbitMQ connection drops midway through this sequence, your application state is split. The card is charged, but the receipt queue is empty. Writing custom recovery routines for every potential connection drop across three separate data stores is extremely difficult.
With Postgres, this problem vanishes. You can record the transaction, queue the background job, and write the cache log inside a single atomic database block:
BEGIN;
INSERT INTO billing_transactions (user_id, amount) VALUES (42, 99.00);
INSERT INTO job_queue (payload) VALUES ('{"type": "receipt", "user_id": 42}');
INSERT INTO cache_entries (key, value) VALUES ('user_99_txn', '{"status": "paid"}');
COMMIT;
If any step fails, the entire transaction rolls back.
The decision framework (do this math first)
Before you move a single table, run through this concrete checklist:
Throughput test — Are you consistently below 10k req/s for the cache/queue/document workloads combined? If your Redis instance is humming at 80k ops/sec, don’t replace it with an UNLOGGED table. The second you cross that 10k line, Postgres’s lock manager and snapshots become the bottleneck.
Dataset size — Is the total hot data (cache entries, queue backlog, documents) under 1 TB? Postgres can handle terabyte-scale easy, but once your cache alone is 2 TB and gets rebuilt frequently, autovacuum will hate you. Redis or Mongo can shard that out without the same vacuum pain.
Relational coupling (the JOIN test) — Can you write a
JOINbetween a queue message and a user row, or a cache entry and a product SKU? If yes, you’re going to love having everything in one engine—foreign keys, cascading deletes, consistent reads across the board. If the data is completely standalone (like a full-text search index of blog posts), the gain is smaller.Team size — If you’re a small team, four databases will kill your on-call rotation. I’ve seen a “full-stack” developer spend a week debugging a RabbitMQ partition brain-split because nobody on the team knew the
rabbitmqctlfor it. One engine—even if you need a contractor later—is easier to teach.Cross-system transactional consistency — Is the charge-card-plus-queue scenario real for you, or a theoretical nice-to-have? If your app can tolerate eventual consistency with a dead-letter queue, the argument weakens.
Cache: UNLOGGED tables vs Redis
Postgres can do a fast, non-durable, in-memory-like cache using UNLOGGED tables. The DDL is dead simple:
CREATE UNLOGGED TABLE cache (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
-- Partial index to help periodic cleanup
CREATE INDEX idx_cache_expired
ON cache (expires_at)
WHERE expires_at < now(); -- reindex occasionally or recreate after cleanup
UNLOGGED tables bypass the write-ahead log for data, so inserts are blazing fast—often two to three times faster than a regular table. The price? The table is truncated automatically after a crash or unclean shutdown. That’s fine for a cache that can be repopulated, much like Redis with only an RDB snapshot that might be hours old.
For TTL expiry, run a periodic cleanup (pg_cron, or an app-side cron) that hits the partial index directly:
DELETE FROM cache WHERE expires_at < now();
-- After cleanup, you may want to REINDEX TABLE cache to keep the index small.
Compare this to Redis: Redis gives you sub-millisecond single-key GETs under heavy concurrency, built-in TTL eviction, and a rich set of data structures. A Postgres cache will rarely get you sub-ms on a cold read (b-tree lookups are fast but not Redis-fast). Redis still wins when:
- You need consistently sub-ms p50 latency on thousands of concurrent connections.
- You’re doing pub/sub fan-out at scale—Postgres LISTEN/NOTIFY crumbles under thousands of listeners.
So use the UNLOGGED table when your cache values naturally relate to relational rows (like a user session row you want to JOIN against permissions) and when your total cache read volume stays in the few-thousands-per-second range.
Queue: SKIP LOCKED vs RabbitMQ/SQS
A job queue in Postgres looks like this:
CREATE TABLE job_queue (
id BIGSERIAL PRIMARY KEY,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT now(),
started_at TIMESTAMPTZ,
attempts INT DEFAULT 0
);
CREATE INDEX idx_jobs_pending ON job_queue (status, created_at)
WHERE status = 'pending';
Workers fetch jobs using the FOR UPDATE SKIP LOCKED idiom, which ensures no two workers grab the same row:
BEGIN;
SELECT id, payload
FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- Process the job externally, then:
UPDATE job_queue SET status = 'done', started_at = now()
WHERE id = ;
COMMIT;
This pattern works beautifully for low to moderate throughput. If a worker crashes before finishing, the transaction rolls back and the row remains pending, ready for another worker. Combine it with a periodic “dead letter” scanner that checks for jobs that have been started_at but not updated in X minutes, and you have a robust, single‑database queue.
When RabbitMQ/SQS is still better:
- Throughput above ~5,000 jobs/second. Postgres’s lock manager will become a contention point.
- Large fan‑out scenarios (one message to a hundred queues). Dedicated brokers are built for this.
- Long‑running jobs that hold a snapshot for hours. You’d need to split into small batches, which defeats the purpose.
For the 90th percentile of SaaS apps—a few million jobs a day, simple worker pools—the Postgres queue is a game changer.
Documents: JSONB vs MongoDB
MongoDB’s main advantage is a flexible schema and horizontal scale‑out. But in Postgres, you get a mature JSONB type with full indexing, operators, and this killer feature: you can JOIN a document with normal relational tables.
A common pattern is storing event payloads or user profiles as JSONB:
CREATE TABLE audit_events (
id BIGSERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
event_data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_events_user ON audit_events (user_id);
CREATE INDEX idx_events_data ON audit_events USING GIN (event_data);
-- For specific JSON paths:
CREATE INDEX idx_events_type ON audit_events ((event_data->>'type'));
You can query event_data->>'ip' directly, filter with @> containment, and still enforce foreign keys across the relational boundary. This is something Mongo cannot do natively.
When MongoDB still wins:
- Your workload is entirely document‑oriented with no relational joins.
- You need to shard across multiple nodes because your document store exceeds a few terabytes. Postgres can be sharded (Citus, pgPartman) but it’s not as seamless as Mongo’s built‑in auto‑sharding.
- You rely on the aggregation pipeline heavily and don’t need SQL. The SQL/JSON path expressions are powerful but not an exact drop‑in replacement for every pipeline stage.
The sweet spot for JSONB is when 80% of your data sits in relational tables and 20% benefits from schema‑flexible documents—all living in the same database, with one set of backups and one transaction log.
Where consolidation breaks: failure modes you must plan for
Unifying on Postgres is not a free lunch. Here are the sharp edges:
Write amplification and autovacuum: A high‑churn cache or queue table will force autovacuum to run constantly. If you don’t tune it, you’ll see dead tuple bloat and performance cliffs. Partition your queue by time (e.g., daily tables) so you can drop old partitions instantly instead of deleting millions of rows.
Connection exhaustion: One database handling cache reads, queue polls, and standard OLTP queries can exhaust your connection pool. Use a connection pooler like PgBouncer, and set sensible timeouts for each workload.
Single‑writer bottleneck: A single Postgres primary can only write so fast. If your combined write load from cache updates, job enqueues, and document inserts exceeds ~50,000 single‑row inserts per second, you’ll need to either scale vertically or split back out.
LISTEN/NOTIFY caveats: It’s tempting to replace Redis pub/sub with Postgres’s NOTIFY. But NOTIFY does not queue messages; if no listener is connected, the event is lost. For reliable delivery, stick with the SKIP LOCKED polling model.
Conclusion
Moving your cache, queue, and document store into Postgres isn’t for every team or every workload. But when you fit the profile—moderate scale, small team, strong transactional coupling—it eliminates a staggeringly large amount of complexity. You’ll have one set of credentials, one backup script, and zero sync bugs between systems.
Start small. Pick one
Top comments (0)