
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;
}
On a cache hit, an expensive database read disappears from the request path.
For example:
Database read: ~800ms
Redis hit: ~30ms
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
}
Redis still contains an older copy:
{
"id": "123",
"name": "Mechanical Keyboard",
"price": 99
}
The application checks Redis first:
const cached = await redis.get("product:123");
if (cached) {
return JSON.parse(cached);
}
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}`);
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
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"),
]);
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
With several cached representations:
update product
|
+--> database
+--> product cache
+--> category cache
+--> list cache
+--> featured cache
+--> search cache
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);
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;
}
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
Now Redis becomes unavailable.
10,000 requests/minute
Redis hits: 0
PostgreSQL reads: 10,000
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]
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,
});
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),
});
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
It expires.
Before any request has rebuilt it, 100 requests arrive.
Each one executes:
const cached = await redis.get(key);
if (!cached) {
return loadFromDatabase();
}
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
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;
}
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
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 ...
Maybe the query is scanning 800,000 rows because an index is missing.
After fixing the query:
Before index: ~400ms
After index: ~30ms
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
It is requested frequently and probably changes infrequently.
A user dashboard is different.
dashboard:user:4821
It might contain:
permissions
subscription state
usage limits
billing information
recent activity
account settings
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
Then requirements arrive.
Different currencies:
product:123:USD
product:123:EUR
Different locales:
product:123:en
product:123:fr
Different tenants:
tenant:42:product:123
tenant:91:product:123
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]
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.
Comments (0)
Login to post a comment.