DEV Community

Othmane
Othmane

Posted on AI-assisted

I made a Redis cache fast enough to hide a bug

I made a Redis cache fast enough to hide a bug.

The application was a small bookstore. Every uncached request deliberately spent 1.5 seconds in PostgreSQL before returning the catalog.

Then I added cache-aside with a 180-second TTL.

The next request was fast. I inserted a new book into PostgreSQL, refreshed the page, and the book was still missing. The database was correct. Every container was healthy. The customer was reading the old catalog.

I built that failure into a Torollo roadmap on purpose.

> Torollo cache-aside walkthrough: the HTTP check fails because Redis serves a stale catalog, DEL cache:books invalidates the entry, and the repeated check finds The Phoenix Project.

The cache-aside path

The application checks Redis before touching PostgreSQL:

CACHE_KEY = "cache:books"
CACHE_TTL = 180

body = cache.get(CACHE_KEY)

if body is None:
    cur.execute("SELECT pg_sleep(1.5);")
    cur.execute("SELECT title, author FROM books ORDER BY id;")
    rows = cur.fetchall()

    body = render_catalog(rows)
    cache.setex(CACHE_KEY, CACHE_TTL, body)
Enter fullscreen mode Exit fullscreen mode

The first request misses the cache, queries PostgreSQL and stores the rendered catalog.

The next request finds cache:books and returns it without running the database query again.

That is the part every cache-aside diagram explains. The more interesting part begins when the database changes.

The infrastructure was healthy

I added a book directly to PostgreSQL:

INSERT INTO books (title, author)
VALUES ('The Phoenix Project', 'Gene Kim');
Enter fullscreen mode Exit fullscreen mode

PostgreSQL now contained the new row. Redis still contained the HTML generated before the insert.

Refreshing the store returned the cached HTML, so the new book remained invisible until one of two things happened:

The 180-second TTL expired
Enter fullscreen mode Exit fullscreen mode

or:

DEL cache:books
Enter fullscreen mode Exit fullscreen mode

Redis was not malfunctioning. It returned exactly the value the application had stored.

The failure was in the invalidation strategy.

Checking the configuration would miss the bug

Torollo runs the web server, PostgreSQL and Redis as real Docker containers.

A weak validator could check that:

  • the Redis container is running;
  • cache:books exists;
  • the key has a TTL.

Every one of those checks would pass while the application displayed stale data.

The roadmap instead sends an HTTP request to the running web application and checks the response:

{
  "type": "http_get_contains",
  "params": {
    "node": "web",
    "port": 80,
    "path": "/",
    "expectedText": "Phoenix"
  }
}
Enter fullscreen mode Exit fullscreen mode

The validation fails because the user-visible result is wrong, even though the infrastructure looks healthy.

After deleting cache:books, the next request goes back to PostgreSQL, rebuilds the cached value and includes the new book. A second request confirms that Redis is serving the corrected catalog.

Availability and freshness are different failures

The application also treats Redis as an optimization.

Redis calls have short timeouts. If the cache becomes unavailable, the application falls back to PostgreSQL. The page becomes slower, but it can still respond.

That protects availability. It does not protect freshness.

A healthy cache can return stale data much faster than a database can return the correct data. Monitoring container health or request latency alone will not expose that mistake.

The behavior has to be tested.

Reproduce the failure locally

Torollo is an open-source system-design lab backed by real local containers.

Run it with:

npx torollo start
Enter fullscreen mode Exit fullscreen mode

Open Learning, choose Cache-aside with Redis, and follow the exercise until the stale catalog step.

The source is available on GitHub:

https://github.com/Derssa/Torollo

When a database write changes several cached views, where do you perform invalidation in your systems: inside the write path, through an event consumer, or by accepting a bounded TTL window?

Top comments (0)