← You Don't Need Redis

Chapter 6: Cache Invalidation: A Matter Requiring Immediate Attention

The Waiter of Gold Lapel · Updated Mar 30, 2026 Published Mar 11, 2026 · 18 min

Phil Karlton of Netscape observed, circa 1996, that there are only two hard problems in computer science: cache invalidation and naming things.

He was not joking. He was identifying a structural difficulty that has not diminished in the intervening thirty years — largely because the industry has spent those thirty years attempting to solve it with the wrong architecture.

Cache invalidation is the problem of ensuring that cached data reflects the current state of the source data. When your cache and your database are separate systems — Redis sitting beside PostgreSQL, each maintaining its own copy of reality — every write to the database potentially invalidates an unknown number of cached entries. The relationship between what changed and what must be evicted is complex, nonlinear, and astonishingly easy to get wrong. This is not a matter of developer discipline or code review rigor. It is a structural property of the architecture itself: two independent systems that must agree on the state of the world, coordinated through application code written by humans who occasionally forget an edge case.

When your "cache" is a materialized view inside the same database as your data, cache invalidation reduces to a single operation: REFRESH MATERIALIZED VIEW. One command. Atomic. Transactional. No stale entries. No orphaned keys. No distributed coordination. No 3 AM pages because the profile cache is showing last Tuesday's order count.

I have rather strong feelings about this, as you may have gathered. Allow me to substantiate them.

The Invalidation Trap

Consider a modest e-commerce application. You cache a user's profile page, which displays their name, their order count, their loyalty tier, and their three most recent orders. The cache key is user:profile:{user_id}. The Redis entry is set with a TTL of one hour, because someone decided that was reasonable and nobody has revisited the decision since.

When must this cache entry be invalidated — not when will it expire, but when must it be updated because the underlying data has changed?

When the user updates their name. That's obvious. One trigger, one table, one cache key.

When a new order is placed. The order count changes. The recent orders list changes. That's two fields affected by one event on a different table. The order service must know about the profile cache and emit an invalidation call.

When an existing order is canceled or refunded. The order count decreases. The recent orders list may change. The same invalidation surface as a new order, but triggered by a different code path — the cancellation handler rather than the checkout handler.

When the user's loyalty tier recalculates. This happens when their lifetime spend crosses a threshold. Which happens when a payment is confirmed. Which may happen asynchronously, hours after the order was placed, through a webhook handler that processes payment confirmations in a background job. The background job must know about the profile cache. The webhook handler must know about the profile cache. The payment confirmation pathway — which may traverse a third-party payment provider, a webhook receiver, a job queue, and a loyalty calculation service — must culminate in a cache invalidation call for a user profile that was cached by a completely different part of the application.

That is five invalidation triggers for one cache key, spanning three database tables and an asynchronous payment workflow. Miss any one of them and the user sees stale data. Implement all of them and you have written a distributed consistency mechanism that must be maintained, tested, and verified every time your data model changes, every time a new code path touches one of those three tables, every time the payment workflow is modified.

Now multiply this across every cached entity in your application. Product pages that aggregate reviews, inventory levels, pricing tiers, and category data. Dashboard widgets that combine sales figures, user activity metrics, and operational data. API responses that join customer records with subscription status with feature flags with usage quotas. Each entity has its own web of invalidation triggers. Each new relationship in your data model adds triggers to every cache key that depends on it.

The complexity does not scale linearly. It scales with the number of relationships in your data model — which, in any application of meaningful complexity, is considerably larger than the number of tables.

I call this the invalidation trap: the exponentially growing web of cache-to-data dependencies that transforms a "simple caching layer" into a distributed consistency nightmare that no amount of discipline, code review, or testing can fully contain. Not because the developers are incompetent — they are not. But because they are being asked to solve a distributed consistency problem through manual coordination, and distributed consistency problems are not solved by being careful. They are solved by eliminating the distribution.

The failure modes are three, and every experienced developer has encountered all of them.

Over-invalidation: clearing caches too aggressively, evicting entries that haven't actually been affected by the data change. The cache hit rate drops. The performance benefit of caching diminishes. The database receives a flood of queries that the cache was supposed to absorb. You have installed a caching layer that periodically makes your application slower than it would be without one.

Under-invalidation: missing a trigger. A code path modifies data but doesn't emit the cache invalidation call. The cache silently serves stale data. Users see inconsistent information. The bug is reported days later, reproduced with difficulty, and traced through three services to a single missing line in a handler that was written six months ago by someone who didn't know the profile cache existed.

Race conditions: a read fills the cache with the old value after the invalidation signal fires but before the write commits. The sequence: (1) write begins, (2) invalidation fires, (3) cache is cleared, (4) a concurrent read fills the cache with pre-write data, (5) write commits. The cache now contains permanently stale data that will persist until the TTL expires or another invalidation fires. This class of bug is nearly impossible to reproduce in testing because it requires specific timing of concurrent operations that occur naturally in production and almost never in development.

Every caching guide tells you to "invalidate on write." None of them tell you how to guarantee that every write, across every code path, in every service, under every concurrency condition, correctly identifies and evicts every affected cache entry. The reason they don't tell you is that there is no reliable way to do it when cache and database are separate systems.

Unless, of course, you eliminate the separation.

UNLOGGED Tables: The Redis Replacement You Already Have

Before I present the materialized view solution for complex data, allow me to address the simpler use case: developers who use Redis primarily as a key-value store for session data, API response caching, or simple computed values. For these use cases, PostgreSQL's UNLOGGED tables provide a closer equivalent than most developers realize.

An UNLOGGED table bypasses the Write-Ahead Log — the mechanism PostgreSQL uses to ensure crash recovery and replication. Without WAL logging, writes are significantly faster. Data persists across clean server restarts — a graceful pg_ctl stop preserves the data — but is truncated after a crash. This is precisely the durability profile you want for cache and session data: fast writes, acceptable loss on failure, no need for replication.

cache_table.sql
CREATE UNLOGGED TABLE cache (
    key TEXT PRIMARY KEY,
    value JSONB NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_cache_expires ON cache (expires_at);

Write a value:

cache_write.sql
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;

Read a value:

cache_read.sql
SELECT value FROM cache
WHERE key = $1 AND expires_at > NOW();

Clean up expired entries with pg_cron:

cache_purge.sql
SELECT cron.schedule('purge-cache', '*/5 * * * *',
    'DELETE FROM cache WHERE expires_at < NOW()');

The performance characteristics are well-documented. Greg Sabino Mullane's benchmarks show UNLOGGED tables achieving approximately 2.9 times faster writes than regular tables — 485,000 transactions per second at 2.059 milliseconds per operation, compared to 168,000 TPS at 5.949 milliseconds for logged tables. CYBERTEC's May 2025 comparison found PostgreSQL UNLOGGED tables approximately twice as slow as Redis for read operations at scale — a gap that is real in benchmarks and invisible in practice for the vast majority of applications.

One benchmark comparing PostgreSQL UNLOGGED against Redis for 100,000 multi-threaded operations found them within 10% of each other: 2,880 milliseconds for PostgreSQL versus 2,618 milliseconds for Redis. Not identical. Not meaningfully different for any workload where the caching layer is not the bottleneck — which is to say, nearly all of them.

The honest comparison: Redis is faster in absolute terms. For session operations — read a session, write a session, extend a session's TTL — the latency difference is perhaps 0.4 milliseconds per operation. Your users will not notice 0.4 milliseconds. Your engineers will notice one fewer service to monitor, backup, secure, patch, and explain to the on-call rotation.

UNLOGGED tables are not a complete Redis replacement. They do not provide Redis's data structures — sorted sets, HyperLogLog, streams, bitmaps. They do not scale horizontally via Redis Cluster. They do not provide Redis's pub/sub at very high message rates. They provide a key-value store with SQL access, JSONB flexibility, and the operational simplicity of living inside your existing database. For the use case of SET key value EX 3600 and GET key — which is the use case that brings most applications to Redis in the first place — they are more than sufficient.

LISTEN/NOTIFY: Your Pub/Sub Is Already Installed

Chapter 5 covered the mechanics of PostgreSQL's LISTEN/NOTIFY in the context of event-driven materialized view refresh. I shall not repeat the implementation here — the trigger, the listener, the debounce pattern are all in that chapter. What I shall do is place LISTEN/NOTIFY in the context of cache invalidation specifically.

The pattern is straightforward: a trigger on your source table sends a notification when data changes. Your application listens on the channel and responds — clear a framework cache entry, refresh a materialized view, send a WebSocket update to the client's browser. The database detects the change. The application decides what to do about it.

Rails ActionCable supports PostgreSQL LISTEN/NOTIFY natively as a backend for WebSocket connections. Rails 8's SolidCable takes a different approach — polling a database table rather than LISTEN/NOTIFY — but achieves the same result: WebSocket connections backed by PostgreSQL, no Redis required. Django Channels can be configured for PostgreSQL-backed pub/sub through third-party channel layers. The Node.js pg library provides direct notification access through client.on('notification'). Each framework can receive database change events without Redis, without Kafka, without any message broker beyond the PostgreSQL instance already running.

The limits are real and worth knowing. LISTEN/NOTIFY does not persist messages — if the listener is disconnected when a notification fires, that notification is lost. There are no consumer groups, no message replay, no delivery guarantees, no partitioning. It replaces Redis pub/sub for lightweight application events: "this data changed, act accordingly." It does not replace Kafka for event-driven architectures with replay, consumer groups, and guaranteed delivery semantics.

Use LISTEN/NOTIFY for application events. Use Kafka for event architectures. Know which one your problem actually requires — and be honest about the answer, because in my experience, the vast majority of applications using Kafka for internal notifications would be better served by a database notification and a five-second debounce.

The Database-Native Alternative

Allow me to return to the user profile from Section 1 — the one with five invalidation triggers, three tables, and an asynchronous payment workflow — and show you what it looks like when cache and database are the same system.

mv_user_profile.sql
CREATE MATERIALIZED VIEW mv_user_profile AS
SELECT
    u.id AS user_id,
    u.name,
    u.email,
    COUNT(o.id) AS order_count,
    COALESCE(SUM(o.total), 0)::numeric(13,2) AS lifetime_spend,
    CASE
        WHEN COALESCE(SUM(o.total), 0) >= 10000 THEN 'platinum'
        WHEN COALESCE(SUM(o.total), 0) >= 5000 THEN 'gold'
        WHEN COALESCE(SUM(o.total), 0) >= 1000 THEN 'silver'
        ELSE 'bronze'
    END AS loyalty_tier,
    (SELECT jsonb_agg(recent ORDER BY recent->>'created_at' DESC)
     FROM (
         SELECT jsonb_build_object(
             'id', o2.id,
             'total', o2.total,
             'status', o2.status,
             'created_at', o2.created_at
         ) AS recent
         FROM orders o2
         WHERE o2.user_id = u.id
           AND o2.status IN ('confirmed', 'shipped', 'delivered')
         ORDER BY o2.created_at DESC
         LIMIT 3
     ) sub
    ) AS recent_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
    AND o.status IN ('confirmed', 'shipped', 'delivered')
GROUP BY u.id, u.name, u.email
WITH DATA;

CREATE UNIQUE INDEX ON mv_user_profile (user_id);

The API endpoint: SELECT * FROM mv_user_profile WHERE user_id = $1.

One query. One index lookup. Sub-millisecond. Every field the profile page needs — name, order count, lifetime spend, loyalty tier, recent orders — pre-computed and stored.

The five invalidation triggers from Section 1? They collapse into one:

refresh_concurrently.sql
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_user_profile;

One command. Atomic. Transactional. Every user's profile is consistent with every other user's profile. No partial updates — you will never see a state where user 42's order count reflects a new order but their loyalty tier hasn't recalculated. No orphaned entries — there is no separate cache store where a key might persist after the data it references has changed. No race conditions — the refresh is a single database operation with transactional guarantees, not a sequence of cache writes coordinated across services.

The timing question — "when should I invalidate?" — becomes the refresh scheduling question from Chapter 5, which has well-understood, well-tested solutions: pg_cron for periodic refresh, LISTEN/NOTIFY for event-driven refresh, the flag table pattern for conditional refresh. Each of these was designed for exactly this purpose, and each operates within the database's transactional guarantees.

This works not because materialized views are a cleverer kind of cache. It works because they eliminate the structural cause of cache invalidation's difficulty: the distribution. Two systems maintaining independent copies of reality, coordinated through application code, is a distributed consistency problem. One system maintaining one copy of reality, refreshed by a single command, is a database operation. The former is structurally intractable for complex data models. The latter is straightforward.

How each framework handles caching — and where each fails

Every major framework provides caching abstractions. Each has strengths. Each has a gap that materialized views fill.

Django offers django-cacheops for automatic ORM-level caching with invalidation and django-cachalot for query-level caching. Both are backed by Redis by default. Both work well for individual model lookups — fetch a user by ID, cache the result, invalidate when the user model changes. Both struggle with complex aggregation queries that span multiple models: a dashboard query joining users, orders, products, and events cannot be automatically invalidated by cacheops because the cache key depends on all four models' state simultaneously.

Rails provides the most sophisticated framework-level cache invalidation system available: key-based expiration with touch propagation through associations. When a child record updates, it touches its parent's updated_at, which changes the parent's cache key, which naturally expires the cached version. This is elegant and effective for hierarchical relationships. It fails on cross-model computations — aggregations, window functions, queries that don't map to a single model's timestamp.

Spring Boot offers @Cacheable, @CacheEvict, and @CachePut annotations backed by any JSR-107 cache provider. The declaration is elegant: annotate a method, and its return value is cached. The invalidation is manual: @CacheEvict must be applied to every method that modifies data relevant to the cached method. Missing one produces a silent stale-data bug. The annotation approach scales with method count, not with data relationship complexity — and it's the relationships that make invalidation hard.

Laravel provides Cache::tags() for grouped invalidation and Eloquent observer-based cache clearing. Tags help: you can clear all caches tagged "orders" when an order changes. But determining which tags a given data change affects — and ensuring every code path applies the correct tags — is the same coordination problem that makes external cache invalidation difficult in every other framework.

Node.js and Go provide no framework-level caching or invalidation abstractions. Developers implement their own, typically with Redis, typically with invalidation logic that covers the obvious cases and misses the subtle ones.

The pattern is universal: framework caching works well for simple individual record lookups and poorly for complex aggregated data. Materialized views handle complex aggregated data. The two approaches do not compete — they complement. Use framework caching for hot individual records where it works. Use materialized views for the aggregations, joins, and computations where framework caching fails.

I call this the three-layer cache model:

Materialized views for aggregated, multi-table, computed data — the hard cache invalidation problems. Dashboards, reports, API responses that join multiple tables. Refreshed by pg_cron or event-driven strategies from Chapter 5.

UNLOGGED tables for simple key-value caching — session data, individual API response caching, rate limiting counters. The Redis SET/GET replacement from earlier in this chapter.

Framework caching for individual hot records — a single user object, a single product, a single configuration value. The use cases your framework already handles well. Keep this layer. It's fine for what it does.

Together, these three layers cover every caching need in a typical application without a single external caching service.

What About Change Data Capture?

The sophisticated reader will be wondering about Debezium.

Debezium is a Change Data Capture platform that watches the PostgreSQL WAL — the same write-ahead log that drives replication — and emits structured events for every data change. It enables sophisticated cache invalidation in downstream systems: keep an Elasticsearch index in sync with your PostgreSQL data, feed a data warehouse in near-real-time, propagate changes to microservice databases, or invalidate Redis caches based on actual data changes rather than application-level guesswork.

It is powerful. It is well-maintained. It solves the invalidation problem at the infrastructure level rather than the application level.

It also adds Kafka (or a Kafka-compatible broker), a Debezium connector deployment, and consumer applications for each downstream system. That is three new services — with their own monitoring, alerting, configuration, and on-call burden — for a problem that materialized views solve with zero new services when the consumer of the changed data is the same PostgreSQL database.

The boundary is clean: use Debezium when you need to propagate changes to systems that are not PostgreSQL — an Elasticsearch search index, a Snowflake data warehouse, a separate microservice's database. It is the correct tool for that job, and this book does not argue otherwise. Use materialized views when the consumer is your PostgreSQL database. Zero new infrastructure. Zero new failure modes. Zero new entries in the on-call runbook.

Can you use both? Yes. They serve different audiences and operate at different levels. Debezium propagates raw change events to external systems. Materialized views pre-compute aggregated results for internal consumption. They complement each other. They do not compete.

That concludes Part II.

You now have the complete toolkit. Materialized views pre-compute expensive queries and serve them at index-scan speed — a 1,000x improvement is routine, a 9,000x improvement is documented. Seven refresh strategies keep them current, from the trivial (pg_cron: three lines of SQL) to the near-real-time (LISTEN/NOTIFY with debounce) to the automatic (pg_ivm for incremental maintenance). UNLOGGED tables replace Redis for simple key-value caching. LISTEN/NOTIFY replaces Redis for lightweight pub/sub. And the cache invalidation problem — the problem that Phil Karlton identified thirty years ago, the problem that has generated more engineering hours, more 3 AM incidents, and more creative profanity than any other problem in web development — is structurally resolved when your cache and your database are the same system.

When should I use materialized views instead of Redis caching?

When your cache stores computed or aggregated data — dashboard metrics, user profiles with computed fields, search indexes that combine multiple tables. Materialized views are purpose-built for this. Redis remains better suited for simple key-value lookups requiring sub-millisecond latency at massive concurrent scale and for Redis-specific data structures that have no PostgreSQL equivalent.

Can I use both materialized views and Redis?

Yes. Use materialized views for the hard part — the aggregations, the joins, the computations that make cache invalidation intractable. Use Redis or UNLOGGED tables for the easy part — individual record lookups, session storage, rate limiting. Each handles the work it was designed for. Neither needs to handle the other's job.

What about Change Data Capture with Debezium?

Use Debezium when changes must propagate to external systems — Elasticsearch, data warehouses, other databases. Use materialized views when the consumer is your PostgreSQL database. They complement each other. They do not compete.

Part III translates everything you've learned into your specific framework — Python, Node.js, Ruby, Java, PHP, Go, or .NET. Each chapter stands alone. Find yours and begin.

I trust you will find the household considerably better organized from here.