Back to all posts

Redis Is Fast. Your Data Can Still Be Wrong.

Oct 02, 2026
9 min read
Redis Is Fast. Your Data Can Still Be Wrong.

The product page loads in 900 milliseconds. Someone adds Redis. Now it loads in 70.

That feels like the end of the story.

Then a price changes in PostgreSQL and the product page keeps showing the old value. One user sees the new price, another sees the cached one, and checkout has to decide which reality to charge.

Redis did not create the inconsistency. We did, the moment we copied data without deciding how that copy becomes stale.

This is the useful way to think about Redis: not as a faster database, and not as a performance switch. It is a fast, shared, in-memory data structure server. The moment we put something there, we need to know whether it is a cache, a session, a counter, a lock, a queue, or the actual source of truth.

Those jobs have different failure rules. Mixing them is where the magic starts, and magic is difficult to debug.

Start with one slow read

Our checkout needs a product summary before creating an order. The database query joins current price, availability, and a few display fields. It is read frequently and changes much less often than it is requested.

That is a reasonable cache candidate.

The common cache-aside flow looks like this:

async function getProductSummary(productId: string) { const key = `product-summary:${productId}`; const cached = await redis.get(key); if (cached) { return ProductSummary.parse(JSON.parse(cached)); } const product = await productRepository.getSummary(productId); await redis.set(key, JSON.stringify(product), { EX: 300 }); return product; }

Read Redis first. On a miss, read PostgreSQL, cache the result for five minutes, and return it.

The database remains the source of truth. Redis holds a disposable copy. If the cache is flushed, the application gets slower while it warms again, but it does not forget what a product costs.

That last sentence is the design test I use most often:

If deleting Redis destroys the truth, you are not using it as a cache.

There are valid systems where Redis owns important data. They need persistence, replication, backup, and recovery designed accordingly. That is a deliberate database choice, not "we already had Redis running."

A TTL is not an invalidation strategy

The five-minute expiration prevents the entry from living forever. It does not tell us whether five minutes of stale pricing is acceptable.

For a blog post list, it may be. For inventory, probably not. For authorization, almost certainly not.

Time-to-live is a business decision disguised as a number. It should come from how long the system can tolerate being wrong, not from a familiar round value copied from another cache.

When a product price changes, we have a few options.

We can delete the cache entry immediately after the database update:

await database.transaction(async (transaction) => { await transaction.products.updatePrice(productId, priceCents); }); await redis.del(`product-summary:${productId}`);

The next read repopulates it. This is simple and often enough.

But notice the gap: the database commit can succeed and the Redis deletion can fail. The stale entry then remains until its TTL expires.

We can reduce the risk with retries or publish an invalidation event through an outbox. We can update the cache directly, but then we must decide what happens if the database succeeds and the cache write fails, or the other way around.

There is no universal "keep it in sync" command. There is only a chosen tolerance for stale data and a recovery path when the two writes do not complete together.

Cache keys are part of the schema

This key looks harmless:

product-summary:42

Then the response shape changes. Old application instances write one JSON structure, new instances expect another, and both use the same key.

The failure looks like an API contract problem because it is one. The cache has a data contract too.

Version keys when the shape changes incompatibly:

product-summary:v2:42

Include every dimension that changes the answer: tenant, locale, currency, permissions, or feature variant. If Canadian and US prices share one key, Redis will return the wrong answer extremely quickly.

At the same time, avoid encoding accidental details into keys. A stable key convention should be readable, bounded, and owned by one module. Scattered string construction creates the same drift as scattered query keys on the frontend.

Fast misses can overwhelm the database

Suppose a popular product's cache entry expires while 2,000 users are viewing it.

All 2,000 requests miss. All 2,000 query PostgreSQL. The cache was supposed to protect the database, but its expiration creates a traffic spike instead.

That is a cache stampede.

One mitigation is request coalescing: the first request acquires a short-lived right to refresh, while the others wait briefly or receive a slightly stale value. Another is adding random jitter to expiration times so thousands of related keys do not disappear in the same millisecond.

const baseTtlSeconds = 300; const jitterSeconds = Math.floor(Math.random() * 60); await redis.set(key, value, { EX: baseTtlSeconds + jitterSeconds, });

For data where brief staleness is acceptable, stale-while-revalidate is often a pleasant trade: return the old value immediately and let one worker refresh it in the background.

The right choice depends on what being stale means. A product description can lag. A stock reservation cannot casually do so.

Sessions are not ordinary cache entries

Redis is also commonly used for server-side sessions.

The browser stores an opaque session id in a secure cookie. Redis maps that id to session state. Every application instance can read it, so a user does not lose their session when the load balancer sends the next request to another server.

This solves the in-memory-session problem from the first article in the series. It also raises the stakes of Redis failure.

If product cache entries disappear, the database can refill them. If all sessions disappear, everyone may be logged out.

Same technology, different product consequence.

Session data needs an intentional lifetime, secure cookie settings, rotation after privilege changes, and a failure policy. It should not contain a convenient copy of the entire user record that silently outlives permission changes.

Store the minimum needed to identify the session and enforce its lifecycle. Redis being fast is not an invitation to stop modelling identity carefully.

Counters fit Redis naturally

Some jobs align especially well with Redis because it can update values atomically.

Rate limiting is a good example. Increment a counter for one user or IP, set an expiration window, and reject requests after the limit:

const key = `rate-limit:create-order:${userId}`; const attempts = await redis.incr(key); if (attempts === 1) { await redis.expire(key, 60); } if (attempts > 10) { throw new TooManyRequestsError(); }

Production implementations usually use a Lua script or a dedicated command pattern so increment and expiration are atomic, and more advanced limits may use sliding windows or token buckets. But the shape explains why Redis fits: a small shared counter, updated frequently, with a natural expiration.

This is not a replacement for abuse protection at the CDN or gateway. It is an application-aware layer: ten checkout attempts may mean something different from ten product-page reads.

Distributed locks are easy to overtrust

Redis can coordinate a short-lived lock between application instances. That can be useful for preventing duplicate cache refreshes or ensuring only one scheduler starts a non-critical task.

It is more dangerous when the lock is treated as proof that a financial or inventory operation happened exactly once.

Processes pause. Networks partition. Lock leases expire. A client may continue working after it no longer owns the lock. Correct distributed locking requires careful ownership tokens, expiration, and usually fencing at the resource being protected.

For shared business truth, I prefer database constraints, idempotency keys, and atomic database updates whenever they can express the rule. A Redis lock can reduce duplicate work. It should not be the only wall protecting an invariant the database can enforce directly.

Decide what happens when Redis is unavailable

Every Redis use needs a failure answer.

  • Product cache unavailable: read from PostgreSQL, perhaps with traffic protection.
  • Rate limiter unavailable: fail open or fail closed, depending on the endpoint and threat.
  • Session store unavailable: reject authenticated operations rather than invent identity.
  • Duplicate-work lock unavailable: skip the optimization or pause the job.

One blanket fallback is not honest because these features carry different risks.

Also watch the fallback itself. If Redis goes down and every request immediately hits PostgreSQL, the database receives the full traffic load at the worst possible moment. Timeouts, connection-pool limits, and circuit breakers keep one dependency failure from becoming two.

The frontend experiences this as latency and uncertainty. A cache outage may not return a clean error; it may make requests slow enough that users retry, which increases traffic further. Pending states need time limits, retries need idempotency, and "try again" should not mean "create another order."

Cache only after you know the expensive path

Redis is not the first fix for every slow endpoint.

Sometimes the query lacks an index. Sometimes the API fetches the same data four times. Sometimes a huge response is serialized on every request. Sometimes the frontend asks for data it does not need.

Caching an inefficient path can hide it until the cache misses under load.

Measure the request. Find the expensive operation. Decide how stale its result may be. Choose the cache ownership and invalidation rule. Then add Redis.

That order makes Redis wonderfully boring. It is a fast copy, a short-lived session, or an atomic counter with an explicit job. Not a mysterious red box that "handles performance."

The next article follows work that should not keep an HTTP request open at all: confirmation emails, exports, image processing, and other jobs that need to finish after the user already has a response.

If your Redis instance disappeared for ten minutes, which user-visible promise would break first? That answer is a much better description of your Redis architecture than the list of commands your code uses.

Telegram

More than a blog post

I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.

Need to discuss your project? Get in touch.