DEV Community

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

Posted 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.

\n\n

``ts\nasync 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;
}
\n```

\n\n

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

For example:

\n\n

text\nDatabase read: ~800ms
Redis hit: ~30ms
\n


\n\n

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:

\n\n

  "id": "123",
  "name": "Mechanical Keyboard",
  "price": 129
}
\n```

\n\n

Redis still contains an older copy:

\n\n

```json\n{
  "id": "123",
  "name": "Mechanical Keyboard",
  "price": 99
}
\n```

\n\n

The application checks Redis first:

\n\n

```ts\nconst cached = await redis.get("product:123");

if (cached) {
  return JSON.parse(cached);
}
\n```

\n\n

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.

\n\n

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

\n\n

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:

\n\n

```text\nproduct:123
products:list
products:category:keyboards
products:featured
search:mechanical-keyboard
\n```

\n\n

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

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

\n\n

```ts\nawait 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"),
]);
\n```

\n\n

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:

\n\n

```text\nupdate product -> database
\n```

\n\n

With several cached representations:

\n\n

```text\nupdate product
      |
      +--> database
      +--> product cache
      +--> category cache
      +--> list cache
      +--> featured cache
      +--> search cache
\n```

\n\n

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:

\n\n

```ts\nconst cached = await redis.get(key);

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

return database.findProduct(id);
\n```

\n\n

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.

\n\n

```ts\nasync 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;
}
\n```

\n\n

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.

\n\n

```text\n10,000 requests/minute

Redis hits:        9,000
PostgreSQL reads:  1,000
\n```

\n\n

Now Redis becomes unavailable.

\n\n

```text\n10,000 requests/minute

Redis hits:            0
PostgreSQL reads: 10,000
\n```

\n\n

The database didn't fail.

Its workload changed by an order of magnitude.

\n\n

```mermaid\nflowchart 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]
\n```

\n\n

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:

\n\n

```ts\nconst CACHE_TTL = 3600;

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

\n\n

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:

\n\n

```ts\nfunction ttlWithJitter(baseSeconds: number) {
  const jitter = Math.floor(Math.random() * 300);

  return baseSeconds + jitter;
}

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

\n\n

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:

\n\n

```text\nhomepage:products
\n```

\n\n

It expires.

Before any request has rebuilt it, 100 requests arrive.

Each one executes:

\n\n

```ts\nconst cached = await redis.get(key);

if (!cached) {
  return loadFromDatabase();
}
\n```

\n\n

All 100 requests observe the same miss.

\n\n

```mermaid\nflowchart 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
\n```

\n\n

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:

\n\n

```ts\nconst 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;
}
\n```

\n\n

Now concurrent requests inside that process can share one rebuild.

But process-local coordination has an important limitation.

Suppose the application runs four instances:

\n\n

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

\n\n

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:

\n\n

```sql\nEXPLAIN ANALYZE
SELECT ...
\n```

\n\n

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

After fixing the query:

\n\n

```text\nBefore index: ~400ms
After index:   ~30ms
\n```

\n\n

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.

\n\n

```text\ncategories:all
\n```

\n\n

It is requested frequently and probably changes infrequently.

A user dashboard is different.

\n\n

```text\ndashboard:user:4821
\n```

\n\n

It might contain:

\n\n

```text\npermissions
subscription state
usage limits
billing information
recent activity
account settings
\n```

\n\n

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:

\n\n

```text\nproduct:123
\n```

\n\n

Then requirements arrive.

Different currencies:

\n\n

```text\nproduct:123:USD
product:123:EUR
\n```

\n\n

Different locales:

\n\n

```text\nproduct:123:en
product:123:fr
\n```

\n\n

Different tenants:

\n\n

```text\ntenant:42:product:123
tenant:91:product:123
\n```

\n\n

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:

\n\n

```mermaid\nflowchart 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]
\n```

\n\n

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](https://zyvop.com/we-added-redis-to-make-things-faster-it-made-things-worse-4ganu)*

💡 For more articles like this, [subscribe to the ZyVOP newsletter](https://zyvop.com/newsletter)!
Enter fullscreen mode Exit fullscreen mode

Top comments (0)