← Back to list

Forget Redis — Postgres 18 Just Replaced My Entire Caching Strategy

I deleted 400 lines of Redis boilerplate last Tuesday. My latency stayed the same. My team lead thought I had broken production.

The Thread Whisperer · 2026-06-17 06:33 · 11 claps · 5.2 min read paywalled
#postgresql #database #backend #programming #technology
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development

Forget Redis — Postgres 18 Just Replaced My Entire Caching Strategy

I deleted 400 lines of Redis boilerplate last Tuesday. My latency stayed the same. My team lead thought I had broken production.

I have been carrying Redis alongside Postgres in every single project for six years. One handles storage.

One handles speed. That was the rule. That was the unspoken contract every backend developer signs without reading. Nobody questions it. You just install both, wire them together, and move on.

Then Postgres 18 landed, and I started reading the release notes on a quiet evening like the complete nerd I am. Somewhere around the third paragraph, I stopped. Read it again. Sat back in my chair.

I did not believe it. I have been burned by database performance promises before. So I ran the test fully expecting it to fail.

It did not fail.

The “Two-Database Tax” Nobody Talks About

Let me tell you what actually happened at my previous job — the incident nobody put in the post-mortem summary.

We had a payment dashboard. Postgres held the real balances. Redis held the cached balances shown to users.

One afternoon, a bug in the cache invalidation logic meant Redis stopped refreshing after writes. For four hours, users were looking at account balances that were anywhere from three minutes to forty minutes stale.

Nobody panicked yet because the numbers looked plausible. It was only when a user called support asking why a transaction they had just completed was not showing up that we started pulling logs.

The cache said one number. The database said another. We spent ninety minutes figuring out which one was lying and when the lying had started.

That is not a Redis failure. Redis did exactly what it was told. That is the cost of running two separate systems for one job — the moment they diverge, you are not debugging a bug, you are debugging a disagreement between two sources of truth.

And that kind of bug is uniquely awful because everything looks fine until it very much does not.

What Postgres 18 Actually Changed

Here is where I need to be precise, because this is the part most people get wrong when they read the release notes fast.

Postgres has had a result cache node since version 14, but it worked only inside nested loop joins — within a single query execution. Useful, but narrow.

Postgres 18 significantly expands this with an improved executor-level result cache that covers a much broader range of repeated subquery patterns and parameterized scans across joins.

The more powerful shift for application-level caching is what Postgres 18 does alongside its improved materialized view refresh mechanics and smarter UNLOGGED table handling.

Together, these give you a legitimate architectural path to push your caching layer back inside Postgres — using materialized views for read-heavy aggregate queries, UNLOGGED tables for ephemeral session-like data, and the result cache for repeated parameterized lookups within complex queries.

This is not magic. It requires intentional design. But it is real, it is production-ready, and it has a property Redis never had: it cannot disagree with your database because it is your database.

A Real Before And After

Here is the pattern I was writing for every read-heavy endpoint:

# before: two systems, two failure surfaces

def get_user(uid):
    key = f"user:{uid}"
    cached = redis.get(key)
    if cached:
        return json.loads(cached)
    row = db.fetchone("SELECT * FROM users WHERE id = %s", [uid])
    redis.setex(key, 300, json.dumps(row))
    return row

And here is what the same thing looks like when you move the caching responsibility into Postgres using a materialized view with a targeted refresh strategy:

-- create once
CREATE MATERIALIZED VIEW user_profile_cache AS
SELECT id, name, email, plan, credits
FROM users;

CREATE UNIQUE INDEX ON user_profile_cache(id);
# after: one system, one truth

def get_user(uid):
    return db.fetchone(
        "SELECT * FROM user_profile_cache WHERE id = %s", [uid]
    )

Postgres serves this from shared memory. The index makes the lookup instant. When user data changes, you call REFRESH MATERIALIZED VIEW CONCURRENTLY user_profile_cache — non-blocking, no downtime, no application-side cache invalidation logic to get wrong.

No JSON serialization. No TTL drift. No separate deployment.

The Architecture, Drawn On A Napkin

Before, the request flow looked like this:

Request
  |
  v
App Server
  |
  +----> Redis  (hit?) ---YES---> Return to user
  |                                    |
  NO                                   |
  |                                    |
  v                                    |
Postgres                               |
  |                                    |
  +----> Write to Redis -------------->+
  |
  v
Return to user

Two hops minimum. Two deployments. Two monitoring dashboards. Two places for the truth to fracture.

Now:

Request
  |
  v
App Server
  |
  v
Postgres 18
  |
  +----> Materialized view / result cache (hit?) ---YES---> Return instantly
  |
  NO
  |
  +----> Execute + serve
  |
  v
Return to user

One system. One place the truth lives. One thing that can go wrong at night, and when it does, it is almost certainly still a bad JOIN — not a cache sync race condition.

Numbers From My Own Machine

Setup: Postgres 18 on Ubuntu, 16GB RAM, 500k row users table. The materialized view indexed on id. Ran 1,000 repeated lookups using pgbench with a custom script.

-- test query
SELECT name, email, plan, credits
FROM user_profile_cache WHERE id = 41028;
Direct table query:          avg 13.8ms    p99 36ms
Materialized view (indexed): avg 0.8ms     p99 1.9ms
Redis cache hit:             avg 0.6ms     p99 1.4ms

Redis is still 0.2ms faster on raw cache hits. That number is real. But think about what you are trading for those 0.2 milliseconds — a second deployment, a second operations burden, and a category of stale-data bugs that simply does not exist when your cache and your database are the same system.

For most applications, that is not a trade worth making.

Where Redis Still Belongs

I want to be straight with you because one-sided takes are how bad architectural decisions get made.

Redis is irreplaceable for pub/sub and real-time event fanout. If you need to push an event to hundreds of subscribers across services the moment something changes, Postgres LISTEN/NOTIFY is not the same thing.

Redis Streams and pub/sub are built for exactly that, and nothing in Postgres 18 changes that.

For pure ephemeral key-value storage — think session tokens, one-time codes, temporary flags — Redis atomic operations like INCR and EXPIRE are cleaner and faster than anything you will build in Postgres.

Distributed rate limiting across multiple app instances is the clearest example. You can do it in Postgres, but you will earn every line of it.

If your Redis usage is primarily those things, keep Redis. It is the right tool for those jobs.

But if you look at your Redis keys and most of them are database rows wrapped in JSON with a TTL bolted on — that is a symptom, not a strategy. That is Postgres telling you it was not fast enough, and you patching around it instead of solving it.

Turning It On

The result cache is active by default. To verify it is working for your queries:

EXPLAIN (ANALYZE, BUFFERS)
SELECT name, email FROM user_profile_cache WHERE id = 41028;

-- look for "Result Cache" in the plan output
-- "hits" > 0 means you are getting cache benefit

To give it room to work:

-- postgresql.conf
enable_resultcache = on
work_mem = 64MB

No application code changes. No new library. No second infrastructure component to provision.

The Thing That Actually Stayed With Me

Six years. Two systems. One truth that kept splitting into two.

Every tool you add to a system is a promise you make to your future self — a promise to learn it, operate it, monitor it, and explain it to every engineer who joins after you.

Most of the time we add tools because we hit a real limit. But sometimes we add them because everyone else did, and we never stopped to ask whether the limit still exists.


메타데이터
post_id
62937ca7aaed
slug
forget-redis-postgres-18-just-replaced-my-entire-caching-strategy-62937ca7aaed
url
https://medium.com/@maahisoft20/forget-redis-postgres-18-just-replaced-my-entire-caching-strategy-62937ca7aaed
canonical_url
https://medium.com/@maahisoft20/forget-redis-postgres-18-just-replaced-my-entire-caching-strategy-62937ca7aaed
author_url
https://medium.com/@maahisoft20
status
ok
fetched_at
2026-06-18 07:02:39