Writing
Performance
Mingtindu Sherpa6 min read

Cache-Aside Pattern with Node.js and Redis

Implement cache hits, database fallback, TTLs, invalidation, tenant-safe keys, and graceful Redis failure handling in Node.js.

On this page

Cache-aside keeps the database authoritative while allowing repeated reads to come from Redis. The application checks the cache, loads from the database on a miss, then stores the result for later requests. That simple flow still needs explicit decisions about stale data, invalidation, failure, and concurrency.

Hit and miss flow

  1. Build a deterministic cache key.
  2. Read the key from Redis.
  3. On a hit, deserialize and return the value.
  4. On a miss, query the database.
  5. Serialize the result and store it with a TTL.
  6. Return the database value.

Redis is a derived copy in this pattern. A missing or unavailable cache should not change the correctness of the database record.

Node.js implementation

This example uses the official redis client and assumes db.product.findUnique is the application's database adapter:

import { createClient } from "redis";
 
const redis = createClient({ url: process.env.REDIS_URL });
redis.on("error", (error) => {
  console.error("Redis client error", error.name);
});
 
await redis.connect();
 
type Product = {
  id: string;
  name: string;
  priceMinor: number;
};
 
const productKey = (tenantId: string, productId: string) =>
  `portfolio-api:prod:v1:tenant:${tenantId}:product:${productId}`;
 
export async function getProduct(
  tenantId: string,
  productId: string,
): Promise<Product | null> {
  const key = productKey(tenantId, productId);
 
  try {
    const cached = await redis.get(key);
    if (cached !== null) return JSON.parse(cached) as Product;
  } catch (error) {
    console.warn("Cache read bypassed", { key, error: String(error) });
  }
 
  const product = await db.product.findUnique({
    where: { tenantId_id: { tenantId, id: productId } },
  });
 
  if (product !== null) {
    try {
      await redis.set(key, JSON.stringify(product), {
        EX: 300,
      });
    } catch (error) {
      console.warn("Cache fill skipped", { key, error: String(error) });
    }
  }
 
  return product;
}

cached !== null distinguishes a stored value from a miss. EX: 300 gives the entry a five-minute expiry, which is an example rather than a universal setting. Database errors still propagate; only cache failures are bypassed.

Do not log cached values or a Redis URL. Even the key may expose identifiers, so use safe structured metadata where IDs are sensitive.

Design cache keys as an API

Keys need a service namespace, environment, schema version, tenant, entity type, and stable identifier. Including the tenant prevents one customer's result from being returned to another. Including a version lets a deployment stop reading an incompatible serialized shape without scanning and deleting every old key.

Normalize query parameters before hashing a list or search key. Two equivalent requests should not create different entries because object keys arrived in a different order. Never accept a raw client-provided Redis key.

Serialization is a contract

JSON does not preserve Date, BigInt, Map, class prototypes, or binary data automatically. Define an explicit cached representation and validate it after parsing. When the shape changes, increment the key version.

Malformed cached data should be treated as a miss and removed, not passed deeper into the application. Do not cache secrets merely because Redis is private; configure authentication, network restrictions, TLS where supported, and encryption requirements appropriate to the data.

TTL bounds staleness but does not solve invalidation

A TTL limits how long an untouched entry remains. Choose it from the business tolerance for stale data and the database's ability to absorb misses. Add small random jitter to large groups of similar keys so they do not expire at exactly the same instant.

After a database write, invalidate the affected cache key:

export async function updateProduct(
  tenantId: string,
  productId: string,
  input: UpdateProductInput,
) {
  const product = await db.product.update({
    where: { tenantId_id: { tenantId, id: productId } },
    data: input,
  });
 
  try {
    await redis.del(productKey(tenantId, productId));
  } catch (error) {
    console.warn("Cache invalidation failed", {
      productId,
      error: String(error),
    });
  }
 
  return product;
}

Write the database first because it is authoritative, then delete the cache entry. There is still a race: a concurrent miss can read the old database value and fill Redis just after the update. Systems with stricter freshness requirements can use versioned keys, an outbox-driven invalidation event, or a carefully designed write-through strategy. A TTL remains a recovery bound for missed invalidation.

Redis failure behavior

Decide whether each endpoint fails open to the database or fails closed. Product descriptions may bypass a broken cache. A cache used as an authorization decision must not silently serve stale permissions and is usually the wrong place for the sole authoritative rule.

Use short connection/command timeouts and a circuit breaker so every request does not wait on an unhealthy Redis instance before reaching the database. Protect the fallback database with pool limits, load shedding, and observability; otherwise a cache outage becomes a database outage.

Prevent cache stampedes

When a hot key expires, many callers may miss together and query the database. Options include:

  • an in-process single-flight promise for each key;
  • a short Redis lock acquired with SET ... NX PX and released only by its owner;
  • refreshing a popular value before expiry;
  • serving an explicitly permitted stale copy while one caller refreshes;
  • TTL jitter.

A distributed lock needs a unique owner token, expiry longer than the expected load, and an atomic compare-and-delete release. Locking is not free; use it for keys whose miss concurrency justifies the complexity.

What should not be cached

Avoid caching highly sensitive secrets, rapidly changing balances or stock without an accepted consistency model, one-time tokens, unbounded user-specific responses, and values that are cheap and rarely reused. Never let cached authorization outlive the policy that grants access.

Negative caching can protect against repeated requests for missing IDs, but use a short TTL and invalidate the sentinel when the entity is created.

Metrics worth monitoring

  • hits, misses, and hit ratio by cache family;
  • Redis command latency, errors, reconnects, and timeouts;
  • fallback database query rate and latency;
  • key count, memory use, evictions, and expired keys;
  • invalidation failures and observed stale reads;
  • lock contention, refresh duration, and stampedes suppressed.

A high hit ratio is not automatically good if the results are stale or the cached queries were cheap. Compare it with database offload and correctness signals. For a broader layer-by-layer view, read Caching strategies that survive invalidation reality.

Verification checklist

  • Keys include environment, schema version, tenant, and entity identity.
  • Cached JSON has a documented and validated shape.
  • Every entry has a business-appropriate TTL and optional jitter.
  • Writes invalidate all affected key families.
  • Redis failure bypasses or fails closed according to explicit policy.
  • Hot-key expiry cannot send unbounded duplicate work to the database.
  • Metrics show both cache behavior and database fallback load.

References

Documentation checked on 2026-08-12:

Related writing

Share