If you’re a backend engineer managing a high-scale Postgres instance, maybe you’ve come across a monitoring dashboard that makes no sense:

  • RAM usage: Healthy (100GB+ available).
  • Shared Buffers Hit Ratio: 99.8% (Almost everything is in memory).
  • Disk I/O: Minimal.
  • CPU Usage: 95% (screaming).

Your database is entirely in memory. Why is it still slow?

The answer lies in a misunderstanding of the database caching hierarchy. To scale read throughput without rewriting your application, you need to understand the difference between Data Caching (what Postgres does) and Query Caching (what you need).

Level 1: The Infrastructure Cache (Shared Buffers & OS)

What it caches: Raw Disk Pages (8KB blocks)

When you tune shared_buffers (recommended to be 25% of RAM), you are telling Postgres to keep the most frequently accessed raw data blocks in memory.

The Hidden Layer: OS Page Cache

It is important to note that Postgres actually relies on a second layer of memory here: the OS Page Cache. If a data page isn’t in shared_buffers, the OS often serves it directly from pages it already has cached in RAM.

The Limit:

If you run SELECT * FROM users WHERE id = 500, and the page is in memory (Shared Buffers or OS Cache), you avoid a disk access. But you do not avoid the CPU work.

Even with a memory hit, Postgres still has to:

  1. Parse the SQL query
  2. Plan the execution
  3. Lock the relevant structures
  4. Scan the relevant pages to find the specific tuple
  5. Check visibility to verify the row is visible to the current transaction
  6. Serialize the data onto the wire

Bottom line: Caching data in memory eliminates disk reads, but it doesn’t eliminate query execution. If your bottleneck is complex joins, aggregations, or heavy read traffic, no amount of shared_buffers will save you.

Level 2: The Query Cache

What it caches: Query Results

To solve the CPU bottleneck, you have to cache the result of the work. This is where engineering teams face a fork in the road: the Manual Way or the Automated Way (I’ll let you guess which one we built).

The Manual Way: Redis / Valkey

Most teams introduce an application-side cache like Redis. You calculate the result, serialize the JSON, and store it with a key like user:500.

  • Postgres Load: 0%.
  • Speed: Instant.

This works perfectly.

Until you have to update the data.

“Invalidation Hell”

There’s an old saying, “There are only two hard things in computer science: cache invalidation and naming things.”

When you update user:500’s email in Postgres, you must remember to delete the key in Redis. This introduces distributed complexity:

  • Inconsistency: What if the DB write succeeds but the Redis delete fails?
  • Staleness: What if you update the user via a migration that doesn’t know about the Redis key?
  • Complexity: If you cache active_users_list, and you add one user, then you have to find and invalidate every list that might contain that user.

Eventually, most teams compromise by setting a short TTL (e.g., 30 seconds). This solves the complexity problem, but you have to accept that your users are always looking at the past.

To be fair, manual caching can be very effective if you have a team with the time and resources to set it all up.

But there are easier ways to cache queries, if you know where to look.

The Automated Way: Transparent Caching with CDC

We believe the ideal database cache should respond like Redis but act like Postgres to your application.

This requires a Transparent Proxy paired with Change Data Capture (CDC).

How it works:

  1. The Proxy: Sits between your app and Postgres. It speaks the wire protocol, intercepting read queries.
  2. The Stream: The cache listens to the Postgres Logical Replication Stream.

The Benefit:

Solving one of the two hard problems in computer science: cache invalidation.

Using the logical replication stream, when a row changes in the database, the cache captures the exact change event. The cache uses this information and performs one of the following actions:

  1. Maintain Freshness: Update the data to keep all of the relevant queries fresh, no manual logic or code necessary.
  2. Precise Invalidation: Use that information to surgically invalidate only the specific queries that relied on that row, instead of flushing entire result sets or serving stale data.

This is what we built at PgCache.

Introducing PgCache

PgCache is a smart read replica for PostgreSQL: a transparent proxy that gives you the performance of query caching with the simplicity of a direct database connection.

It’s live today, and it works like this:

  • Zero Config: Point your app at PgCache instead of port 5432. PostgreSQL 16, 17, and 18.
  • Smart Invalidation: We use CDC to handle the hard work of freshness and invalidation for you.
  • Protocol Level: Works with any Postgres client (Node, Go, Python, Java). No Redis layer, no invalidation logic to write.

If you want the wider map first, we wrote a guide to PostgreSQL caching that puts shared_buffers, materialized views, Redis, pgpool, and read replicas side by side.

Try PgCache

There’s a free tier on AWS Marketplace (and a 30-day free trial for larger instances), so you can point PgCache at a real workload and watch what happens to your CPU. Prefer a conversation first? Set up a technical deep dive or join the mailing list.

PgCache is a better way to cache. We’re eager to help you improve your performance.