Skip to main content
Logo
Overview

Do You Still Need Redis in 2026? PostgreSQL as Your Cache, Queue, and Pub/Sub

June 13, 2026
10 min read

Every few months someone posts “I replaced Redis with PostgreSQL and it’s faster,” it hits the front page of Hacker News and dev.to, and the comment section turns into a small war. The latest wave kicked off after Postgres 18 landed, and by mid-2026 the “do you still need Redis” question has gone from niche contrarian take to something teams are seriously asking in planning meetings.

I’ve run both setups in production. The honest answer is: for a lot of apps, you can drop Redis and not notice. For some apps, doing that will quietly wreck you at 2am six months later. The trick is knowing which app you have before you commit. So let’s go through it by workload — caching, queues, pub/sub, sessions — with real numbers, and then draw the line where Redis genuinely still wins.

Why this argument got loud again

Two things changed the math. First, Postgres 18 (released September 25, 2025) shipped a new asynchronous I/O subsystem. The headline claim is “up to 3x faster reads from storage,” and while that’s a best-case number for sequential scans and vacuums, real production reports in 2026 land more around 20-40% throughput gains depending on how well your storage is configured. Either way, Postgres got meaningfully faster at exactly the kind of read-heavy work a cache does.

Second, and honestly the bigger driver, is the “one fewer moving part” argument. Every piece of infrastructure you run is something that can page you, something to patch, something to monitor, something with its own failover story. If you’re a three-person team, deleting Redis from your stack isn’t a performance decision — it’s a sleep decision. That’s the emotional core of why these posts go viral, and it’s a legitimate reason, not just hype.

The thing nobody screenshots is the asterisk. “Faster” in those posts almost always means faster end-to-end including the network hop you saved, not faster per operation. Per operation, Redis is still quicker. Keep that distinction in your head — it explains most of the disagreement in the comments.

Caching: UNLOGGED tables vs Redis GET/SET

The Postgres trick for caching is the UNLOGGED table. Normal tables write everything to the write-ahead log (WAL) so they survive a crash. An UNLOGGED table skips the WAL entirely. You lose crash durability — the table gets truncated if Postgres crashes hard — but for a cache, who cares? Cache data is rebuildable by definition. In exchange, writes get dramatically faster because you’ve cut out the single biggest source of write overhead.

CREATE UNLOGGED TABLE cache (
  key   text PRIMARY KEY,
  value jsonb NOT NULL,
  expires_at timestamptz
);
 
-- GET
SELECT value FROM cache
WHERE key = $1 AND (expires_at IS NULL OR expires_at > now());
 
-- SET with TTL
INSERT INTO cache (key, value, expires_at)
VALUES ($1, $2, now() + interval '1 hour')
ON CONFLICT (key) DO UPDATE
  SET value = excluded.value, expires_at = excluded.expires_at;

So how much slower is it than Redis? In the benchmarks floating around, Postgres lands somewhere between 50% and 158% slower per operation — which sounds brutal until you put real numbers on it. That’s a difference of roughly 0.1ms to 1ms per call. Redis answers a GET in maybe 0.2-0.5ms over the network; Postgres might take 0.5-1.5ms.

For the overwhelming majority of web apps, that gap is invisible. Your request is already doing a dozen other things that each cost more than a millisecond. And here’s where the “faster” claims come from: if your app talks to Postgres anyway, caching in Postgres means you skip a whole separate network round trip to a separate Redis box. One connection pool, one round trip, done. That saved hop can genuinely net out faster than maintaining a second service — especially in serverless or multi-region setups where the Redis instance might be a region away.

The catch is eviction. Redis does this for you with maxmemory policies like LRU. Postgres has no built-in TTL eviction — expires_at is just a column. You filter expired rows in your query, then run a periodic DELETE WHERE expires_at < now() job (pg_cron works fine) to actually reclaim space. It’s a little more bookkeeping, and if you forget the cleanup job, your “cache” slowly becomes a landfill that eats disk.

Job queues: SKIP LOCKED vs BullMQ

This is where Postgres is genuinely excellent, not just adequate. The magic incantation is SELECT ... FOR UPDATE SKIP LOCKED:

-- A worker grabs the next job, skipping rows other workers already locked
WITH job AS (
  SELECT id FROM jobs
  WHERE status = 'pending' AND run_at <= now()
  ORDER BY run_at
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE jobs SET status = 'running'
WHERE id IN (SELECT id FROM job)
RETURNING *;

SKIP LOCKED means concurrent workers each grab different rows without blocking each other or stepping on the same job. Twenty workers can hammer this table and each one walks away with a distinct job. It’s been battle-tested for years — this is the engine under Solid Queue, GoodJob, River, graphile-worker, and a pile of other libraries.

The thing Redis-based queues like BullMQ fundamentally cannot give you is transactional enqueue. Picture this: a user signs up, you write the user row, and you enqueue a “send welcome email” job. With Redis, those are two separate systems. If your process dies between them, you’ve got a user with no email job, or an email job for a user that didn’t get committed. You’re stuck writing reconciliation logic or accepting the occasional gap.

With Postgres, the job insert is in the same transaction as the user insert. Either both commit or neither does. No outbox pattern, no dual-write problem, no “mostly consistent.” For a lot of backend systems, that one property is worth more than any throughput number.

The honest limit: Postgres queues comfortably handle thousands of jobs per second, which covers the vast majority of apps. If you’re pushing tens of thousands of jobs per second sustained, the row churn and vacuum pressure start to hurt, and a purpose-built broker earns its keep. But be honest about whether you’re actually at that scale or just imagining you might be someday.

Pub/Sub and real-time: LISTEN/NOTIFY and its sharp edges

Postgres has had LISTEN/NOTIFY forever. One connection runs LISTEN channel_name, another runs NOTIFY channel_name, 'payload', and the message gets pushed to listeners. For “tell my app servers to invalidate this cache key” or “a new row landed, go refresh,” it’s lovely and it’s already there.

LISTEN cache_invalidation;
NOTIFY cache_invalidation, 'user:42';

But please understand what it is not. LISTEN/NOTIFY is not a durable stream. If no one is listening when a NOTIFY fires, that message is gone. There’s no replay, no consumer group, no “catch up on what I missed while I was deploying.” If a listener disconnects for two seconds during a restart, it misses everything in that window with zero indication it happened. Redis Streams (and Kafka, and friends) exist precisely because durable, replayable messaging is a hard problem that NOTIFY doesn’t even attempt to solve.

There’s also a payload size limit — NOTIFY payloads max out at 8000 bytes — so you send a pointer, an ID, a key, never the actual data. And NOTIFY only fires on transaction commit, which is usually what you want but occasionally surprises people debugging why their notification “didn’t fire” (it’s sitting in an uncommitted transaction).

Treat LISTEN/NOTIFY as a real-time signal, not a message bus. “Something changed, go look” is its sweet spot. “Reliably deliver every event exactly once even across restarts” is not, and trying to force it into that role is how you end up with mysterious dropped events in production.

Sessions, rate limiting, and the pooler trap

Sessions are the easy win. A JSONB column with an expires_at and the same cleanup-job pattern as caching, and you’re done. Sessions are read-heavy, low-stakes, and benefit from living next to your user data. Rate limiting works too — a counter table or a small sliding-window query handles most needs, though Redis’s atomic INCR and built-in expiry are admittedly more elegant for high-frequency counters.

Now the trap that bites people, and it bites hard: connection poolers in transaction mode break LISTEN/NOTIFY. If you run PgBouncer (or Supavisor, or any pooler) in transaction pooling mode — which is the default recommendation for serverless and high-connection setups — your LISTEN connection gets recycled out from under you between transactions. Your listener silently stops receiving notifications and you’ll spend an afternoon convinced NOTIFY is broken when it’s your pooler.

The fix is to run your listening connections through session-mode pooling or a direct connection, separate from your transactional traffic. This is exactly the kind of thing that doesn’t show up in a “look how easy this is” blog post but absolutely shows up in your incident channel. If you’re already wrestling with pooling — and on serverless Postgres you probably are — factor this in before you bet your real-time features on NOTIFY. (We’ve got a whole post on Postgres connection pooling if you want to go deeper on transaction vs session mode.)

Where Redis still wins, no contest

I don’t want to leave you thinking Postgres eats Redis’s lunch across the board. It doesn’t. There’s a clear set of cases where reaching for Redis (or Valkey, or DragonflyDB) is just correct:

Raw throughput past ~10k ops/sec. Up to roughly that ceiling, Postgres holds its own and the operational simplicity wins. Push toward 100k+ ops/sec of pure cache traffic and Postgres becomes the bottleneck — you’re now tuning vacuum and connection limits to do a job Redis does in its sleep. At that scale Redis isn’t optional, it’s the right tool.

Data structures Postgres can’t fake cheaply. Sorted sets for leaderboards, HyperLogLog for cardinality estimation, bitmaps, geospatial radius queries with GEORADIUS, atomic list operations for real work-stealing queues. You can approximate some of these with SQL, but it’s clumsy and slower, and you’re fighting the database instead of using it. Redis was built for these.

Sub-millisecond latency as a hard requirement. If you genuinely need consistent sub-millisecond responses — ad bidding, high-frequency feature flags in a hot path, real-time gaming state — that 0.1-1ms Postgres penalty is the whole budget. Redis keeps everything in memory for precisely this reason.

You already operate Redis well. If Redis is already in your stack, humming along, and your team knows it cold, ripping it out to save $100/month is busywork dressed up as architecture. The migration cost outweighs the savings.

So, do you drop Redis?

Here’s the decision matrix I’d actually use:

  • Small-to-medium app, already on Postgres, under ~10k ops/sec, no exotic data structures? Drop Redis. The simplicity is real and the performance gap won’t matter. Start with caching and the job queue — those are the safest, highest-value wins.
  • Need transactional job enqueue? Use Postgres regardless of size. SKIP LOCKED plus same-transaction inserts is a genuinely better design than a separate broker for this, not a compromise.
  • Need durable, replayable streaming? Don’t use LISTEN/NOTIFY. That’s Redis Streams or Kafka territory — see our Kafka alternatives roundup if you’re shopping.
  • High throughput, leaderboards, sub-ms latency, or Redis already running smoothly? Keep Redis. This isn’t the fight to pick.

If you want to try it, don’t rip everything out at once. Move your cache to an UNLOGGED table behind a feature flag, watch your p99 latency for a week, and see if anyone notices. If they don’t — and for most apps they won’t — move the job queue next. You can run both systems side by side during the transition and back out cheaply if something surprises you. That’s a far better plan than reading one viral post and deleting your Redis cluster on a Friday.

Sources: PostgreSQL 18 release notes, Get Excited About Postgres 18 — Crunchy Data, PostgreSQL 18 Async I/O in Production benchmarks, Replace Redis with PostgreSQL: UNLOGGED Tables + JSONB — Tihomir Manushev, Can Postgres replace Redis as a cache? — Raphael De Lio