DEV Community

Cover image for We Added Redis to Make Things Faster. It Made Things Worse.
ZyVOP
ZyVOP

Posted on Edited on Originally published at zyvop.com

We Added Redis to Make Things Faster. It Made Things Worse.

A product endpoint is taking around 800ms under load.

The request isn't doing anything unusual: fetch a product from PostgreSQL, serialize it, return JSON. The data changes infrequently, but the same query runs thousands of times.

Redis looks like an obvious fix.

async function getProduct(id: string) {
  const key = `product:${id}`;

  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  const product = await productRepository.findOneBy({ id });

  if (product) {
    await redis.set(key, JSON.stringify(product), {
      EX: 3600,
    });
  }

  return product;
}

Enter fullscreen mode Exit fullscreen mode

On a cache hit, an expensive database read disappears from the request path.

For example:

Database read:  ~800ms
Redis hit:       ~30ms

Enter fullscreen mode Exit fullscreen mode

Those numbers are illustrative, but the improvement can be substantial when the original operation is expensive.

So far, Redis is doing exactly what we wanted.

The interesting problems start when the underlying data changes.

The Database Is Correct. The Response Isn't.

Consider product 123.

PostgreSQL contains:

{
  "id": "123",
  "name": "Mechanical Keyboard",
  "price": 129
}

Enter fullscreen mode Exit fullscreen mode

Redis still contains an older copy:

{
  "id": "123",
  "name": "Mechanical Keyboard",
  "price": 99
}

Enter fullscreen mode Exit fullscreen mode

The application checks Redis first:

const cached = await redis.get("product:123");

if (cached) {
  return JSON.parse(cached);
}

Enter fullscreen mode Exit fullscreen mode

Nothing fails.

The database is healthy. Redis is healthy. The request returns 200 OK.

It also returns the wrong price.

The obvious fix is invalidation.

await productRepository.save(product);
await redis.del(`product:${product.id}`);

Enter fullscreen mode Exit fullscreen mode

For a single cache key, that's straightforward.

Most applications don't have a single cache key.

One Row Can Exist in Several Cached Responses

Product 123 might appear in:

product:123
products:list
products:category:keyboards
products:featured
search:mechanical-keyboard

Enter fullscreen mode Exit fullscreen mode

Updating one database row can make every one of those entries stale.

The write path now needs to know about the read paths:

await productRepository.save(product);

await Promise.all([
  redis.del(`product:${product.id}`),
  redis.del("products:list"),
  redis.del(`products:category:${product.categoryId}`),
  redis.del("products:featured"),
]);

Enter fullscreen mode Exit fullscreen mode

And that still doesn't necessarily handle search results, filtered lists, pagination, or other cached representations containing the product.

This is where caching starts affecting application design.

Without caching:

update product -> database

Enter fullscreen mode Exit fullscreen mode

With several cached representations:

update product
      |
      +--> database
      +--> product cache
      +--> category cache
      +--> list cache
      +--> featured cache
      +--> search cache

Enter fullscreen mode Exit fullscreen mode

Reads became cheaper, but correctness now depends on invalidating every relevant representation.

The more places the same data is cached, the harder that becomes.

Redis Failure Is Not the Same as a Cache Miss

A common cache-aside implementation assumes this:

const cached = await redis.get(key);

if (cached) {
  return JSON.parse(cached);
}

return database.findProduct(id);

Enter fullscreen mode Exit fullscreen mode

But if Redis is unavailable, redis.get() doesn't necessarily return null.

It may throw.

It may also spend time reconnecting or waiting for a network timeout before failing.

So a real fallback needs to treat Redis as a dependency that can fail.

async function getProduct(id: string) {
  const key = `product:${id}`;

  try {
    const cached = await redis.get(key);

    if (cached) {
      return JSON.parse(cached);
    }
  } catch (error) {
    logger.warn({ error, key }, "Redis read failed");
  }

  const product = await productRepository.findOneBy({ id });

  if (!product) {
    return null;
  }

  try {
    await redis.set(key, JSON.stringify(product), {
      EX: 3600,
    });
  } catch (error) {
    logger.warn({ error, key }, "Redis write failed");
  }

  return product;
}

Enter fullscreen mode Exit fullscreen mode

This allows the database path to continue when Redis fails.

It doesn't mean the system is safe.

Suppose Redis normally absorbs 90% of product reads.

10,000 requests/minute

Redis hits:        9,000
PostgreSQL reads:  1,000

Enter fullscreen mode Exit fullscreen mode

Now Redis becomes unavailable.

10,000 requests/minute

Redis hits:            0
PostgreSQL reads: 10,000

Enter fullscreen mode Exit fullscreen mode

The database didn't fail.

Its workload changed by an order of magnitude.

flowchart TD
    A[Redis unavailable] --> B[Requests fall back to PostgreSQL]
    B --> C[Database traffic increases]
    C --> D[Connection pool fills]
    D --> E[Queries wait longer]
    E --> F[Requests remain open longer]
    F --> G[Application concurrency increases]

Enter fullscreen mode Exit fullscreen mode

This is why testing a Redis outage with one request proves very little.

The interesting test is Redis unavailable under normal production traffic.

There is another detail worth handling: the Redis client itself needs sensible connection and command timeouts. A fallback doesn't help much if every request waits several seconds for Redis before reaching PostgreSQL.

TTLs Can Move Load Instead of Removing It

Suppose thousands of cache entries are populated during a batch import or deployment.

They all receive the same TTL:

const CACHE_TTL = 3600;

await redis.set(key, value, {
  EX: CACHE_TTL,
});

Enter fullscreen mode Exit fullscreen mode

If many entries are created around 10:00, many become eligible for expiration around 11:00.

Traffic that was previously hitting Redis starts rebuilding those entries from the database.

Instead of database work being spread across the hour, some of it becomes concentrated around expiration.

A small amount of TTL jitter helps avoid unnecessary synchronization:

function ttlWithJitter(baseSeconds: number) {
  const jitter = Math.floor(Math.random() * 300);

  return baseSeconds + jitter;
}

await redis.set(key, value, {
  EX: ttlWithJitter(3600),
});

Enter fullscreen mode Exit fullscreen mode

Entries created together no longer have identical expiration times.

Jitter doesn't solve cache stampedes, though.

One Expired Key Can Trigger Many Queries

Consider a popular cache entry:

homepage:products

Enter fullscreen mode Exit fullscreen mode

It expires.

Before any request has rebuilt it, 100 requests arrive.

Each one executes:

const cached = await redis.get(key);

if (!cached) {
  return loadFromDatabase();
}

Enter fullscreen mode Exit fullscreen mode

All 100 requests observe the same miss.

flowchart TD
    A[homepage:products expires] --> B[Request 1]
    A --> C[Request 2]
    A --> D[Request 3]
    A --> E[Request 4...]

    B --> F[Expensive database query]
    C --> F
    D --> F
    E --> F

Enter fullscreen mode Exit fullscreen mode

The cache normally prevents repeated work, but during that window it provides no coordination between callers.

This is a cache stampede.

For a single Node.js process, duplicate work can be coalesced with an in-flight promise:

const pending = new Map<string, Promise<unknown>>();

async function loadOnce<T>(
  key: string,
  loader: () => Promise<T>,
): Promise<T> {
  const existing = pending.get(key);

  if (existing) {
    return existing as Promise<T>;
  }

  const request = loader().finally(() => {
    pending.delete(key);
  });

  pending.set(key, request);

  return request;
}

Enter fullscreen mode Exit fullscreen mode

Now concurrent requests inside that process can share one rebuild.

But process-local coordination has an important limitation.

Suppose the application runs four instances:

             Load Balancer
          /       |       \
       API-1    API-2    API-3    API-4
         |        |        |        |
         +--------+--------+--------+
                          |
                     PostgreSQL

Enter fullscreen mode Exit fullscreen mode

Each instance has its own pending map.

The same expired key can therefore trigger one rebuild per instance.

Four instances may produce four expensive queries instead of 100. That's much better, but it isn't globally coordinated.

For expensive or high-traffic keys, options include distributed locking, stale-while-revalidate, background refresh, or other forms of cross-instance request coordination.

Which one makes sense depends on how expensive stale data and duplicate work are for that particular cache.

Sometimes Redis Is Hiding the Real Problem

Suppose a database query takes 400ms.

Caching it might reduce most requests to a few milliseconds.

Before adding Redis, run:

EXPLAIN ANALYZE
SELECT ...

Enter fullscreen mode Exit fullscreen mode

Maybe the query is scanning 800,000 rows because an index is missing.

After fixing the query:

Before index: ~400ms
After index:   ~30ms

Enter fullscreen mode Exit fullscreen mode

Those numbers are an example, but the point matters: the cache would have hidden a query that should have been fixed.

The same applies to N+1 queries.

Or fetching entire relations when the response needs three fields.

Or repeatedly calling an external service when the data could have been included in the original request.

Caching a slow operation and optimizing a slow operation are different things.

Before deciding to cache an endpoint, it helps to know why the endpoint is expensive.

Not Everything Expensive Should Be Cached

A useful cache candidate usually has some combination of:

  • expensive computation or I/O;

  • frequent reads;

  • relatively infrequent changes;

  • tolerance for some amount of staleness;

  • predictable invalidation.

A public category list might fit well.

categories:all

Enter fullscreen mode Exit fullscreen mode

It is requested frequently and probably changes infrequently.

A user dashboard is different.

dashboard:user:4821

Enter fullscreen mode Exit fullscreen mode

It might contain:

permissions
subscription state
usage limits
billing information
recent activity
account settings

Enter fullscreen mode Exit fullscreen mode

Some of those values can change independently, and some may need stronger freshness guarantees than others.

Caching the entire dashboard as one object may save database work, but it also turns several independent data sources into one invalidation problem.

The question isn't only whether caching makes the endpoint faster.

The question is whether the performance gain is worth the new correctness problem.

Cache Keys Are Part of the Architecture

Cache keys often start simple:

product:123

Enter fullscreen mode Exit fullscreen mode

Then requirements arrive.

Different currencies:

product:123:USD
product:123:EUR

Enter fullscreen mode Exit fullscreen mode

Different locales:

product:123:en
product:123:fr

Enter fullscreen mode Exit fullscreen mode

Different tenants:

tenant:42:product:123
tenant:91:product:123

Enter fullscreen mode Exit fullscreen mode

Different permissions or response variants can add more dimensions.

At that point, cache-key design isn't an implementation detail.

A missing dimension can return stale data.

A missing tenant identifier can return someone else's data.

A key format that is difficult to invalidate can turn a simple update into a broad cache purge.

The key needs to represent every input that can materially change the cached response.

That is easy to say and surprisingly easy to get wrong.

What a Safer Cache-Aside Path Looks Like

A cache-aside read path is still simple conceptually:

flowchart LR
    A[Request] --> B{Cache appropriate?}

    B -->|No| C[Database]
    B -->|Yes| D[Redis]

    D -->|Hit| E[Return cached value]
    D -->|Miss or failure| C

    C --> F[Build response]
    F --> G[Populate cache]
    F --> H[Return response]

Enter fullscreen mode Exit fullscreen mode

The difficult parts are outside that diagram:

  • deciding what deserves to be cached;

  • choosing keys that represent the actual response;

  • invalidating every affected representation;

  • preventing synchronized expiration;

  • controlling expensive rebuilds;

  • handling Redis latency and failure;

  • ensuring the database can survive reduced cache effectiveness.

Redis solves none of those automatically.

Redis Wasn't the Problem

Redis can remove an enormous amount of repeated work from a system.

It can also make a poorly understood performance problem harder to see.

If an endpoint is slow, start with the endpoint.

Measure the query.

Look at the query plan.

Check how much data is being fetched.

Check downstream calls.

Look for repeated computation.

Then decide whether the remaining work is something worth caching.

The better question isn't:

Should this use Redis?

It's:

What work are we avoiding, how long can we reuse its result, and what happens when that result is stale or unavailable?

If those answers are clear, Redis can be remarkably effective.

If they aren't, a 30ms cache hit may simply be hiding the next production problem.


Originally published on ZyVOP

💡 For more articles like this, subscribe to the ZyVOP newsletter!

Top comments (0)