A slow endpoint is not always a database problem. Sometimes the query is valid and indexed, but the application repeats the same work hundreds of times per minute. A Redis cache can remove that repetition—provided you treat it as a disposable acceleration layer rather than a second source of truth.

This tutorial builds a production-minded cache-aside flow for a Node.js and Express API. You will connect with the official redis client, cache JSON with bounded TTLs, invalidate data after writes, reduce cache stampedes, and keep the API useful when Redis is temporarily unavailable.

What You Will Build

Assume an endpoint returns a product from PostgreSQL:

GET /api/products/:id

Without caching, every request queries PostgreSQL. With cache-aside, the request follows this path:

  1. Read a versioned key from Redis.
  2. Return the cached JSON on a hit.
  3. On a miss, query PostgreSQL.
  4. Store the result in Redis with a TTL, then return it.

The database remains authoritative. Redis may be flushed, restarted, or bypassed without losing business data.

When Redis Caching Is Worth It

Caching works best for data that is read frequently, relatively expensive to produce, and acceptable to serve with short-lived staleness. Product details, public profiles, permission summaries, and dashboard aggregates are common candidates.

Do not start by caching everything. Avoid caching rapidly changing balances, one-time security tokens, or inexpensive queries unless you have measured a real bottleneck. A cache adds key design, invalidation, memory limits, and another failure mode. Measure endpoint latency and database load before and after the change.

Install and Connect the Official Node.js Client

Install the official Node.js client:

npm install redis

Keep the connection in one module so the application does not create a client per request:

// src/lib/redis.js
import { createClient } from 'redis';

export const redis = createClient({ url: process.env.REDIS_URL, disableOfflineQueue: true, socket: { connectTimeout: 2_000, reconnectStrategy(retries) { return Math.min(50 * 2 ** retries, 2_000); } } });

redis.on('error', (error) => { console.error('Redis client error', { message: error.message }); });

export async function connectRedis() { if (!redis.isOpen) await redis.connect(); }

The client must have an error listener. Setting disableOfflineQueue is a deliberate API choice here: requests should fall back to PostgreSQL instead of waiting for queued cache commands during an outage. For production, use a private network and a rediss:// connection or the TLS configuration supplied by your managed Redis provider.

Design Keys Before Writing Cache Code

A useful key should identify the application, environment, data type, record, and schema version:

shop:prod:product:v1:42

The version segment gives you a cheap escape hatch. If the serialized shape changes incompatibly, deploy code that reads v2 keys instead of scanning and deleting every old key. Old entries disappear when their TTLs expire.

For multi-tenant systems, include the tenant identifier. Never let user input become an unrestricted key prefix, and never use keys containing passwords, access tokens, email addresses, or other secrets.

const productKey = (tenantId, productId) =>
  `shop:prod:tenant:${tenantId}:product:v1:${productId}`;

Implement a Resilient Cache-Aside Read

The route below treats a cache failure as a miss. It also uses a small random TTL variation, often called jitter, so thousands of entries created together do not all expire during the same second.

import express from 'express';
import { redis } from './lib/redis.js';
import { prisma } from './lib/prisma.js';

const router = express.Router(); const BASE_TTL_SECONDS = 300;

function ttlWithJitter(base) { const jitter = Math.floor(base * 0.2 * Math.random()); return base + jitter; }

router.get('/api/products/:id', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isSafeInteger(id) || id < 1) { return res.status(400).json({ error: 'Invalid product id' }); }

const key = shop:prod:product:v1:${id};

try { if (redis.isReady) { const cached = await redis.get(key); if (cached !== null) { res.set('X-Cache', 'HIT'); return res.json(JSON.parse(cached)); } } } catch (error) { console.warn('Redis read failed', { key, message: error.message }); }

try { const product = await prisma.product.findUnique({ where: { id } }); if (!product) return res.status(404).json({ error: 'Not found' });

if (redis.isReady) {
  redis.set(key, JSON.stringify(product), {
    EX: ttlWithJitter(BASE_TTL_SECONDS)
  }).catch((error) =&gt; {
    console.warn('Redis write failed', { key, message: error.message });
  });
}

res.set('X-Cache', 'MISS');
return res.json(product);

} catch (error) { return next(error); } });

The Redis SET command applies the value and expiration together. That avoids a persistent key if the process dies between separate SET and EXPIRE calls. Redis removes a key after its TTL elapses, so stale entries have a hard upper lifetime even if an invalidation is missed.

Should You Await the Cache Write?

The example does not delay the response for a cache write. That keeps the database result available even when Redis is slow, but it also means a sudden process shutdown might lose the fill. Await the write when warming the cache is more important than the small latency cost. Either approach should catch promise rejections.

Invalidate After a Successful Database Write

The hardest caching problem is deciding when data stops being valid. For a single-record update, keep the rule simple: commit the database write first, then delete the cache key.

router.patch('/api/products/:id', async (req, res, next) => {
  const id = Number(req.params.id);
  const key = `shop:prod:product:v1:${id}`;

try { const product = await prisma.product.update({ where: { id }, data: { name: req.body.name, price: req.body.price } });

if (redis.isReady) {
  await redis.del(key).catch((error) =&gt; {
    console.warn('Redis invalidation failed', {
      key,
      message: error.message
    });
  });
}

return res.json(product);

} catch (error) { return next(error); } });

Deleting after the database succeeds prevents the cache from advertising a value the database rejected. If deletion fails, the TTL bounds the stale period. For stricter consistency across multiple application instances, emit an outbox event in the same database transaction and let a worker retry invalidation.

Avoid updating the cache first and the database second. A database failure would leave a value in Redis that never became authoritative.

Prevent a Cache Stampede

When a popular key expires, many requests can miss simultaneously and all query the database. TTL jitter reduces synchronized expiration across keys, but it does not stop concurrent requests for the same key.

A compact protection is a short-lived lock created with SET ... NX EX:

const lockKey = `${key}:lock`;
const acquired = await redis.set(lockKey, '1', { NX: true, EX: 5 });

if (acquired) { try { const product = await prisma.product.findUnique({ where: { id } }); if (product) { await redis.set(key, JSON.stringify(product), { EX: 300 }); } return product; } finally { await redis.del(lockKey).catch(() => {}); } }

// Another request is refreshing the key. Wait briefly, then retry once. await new Promise((resolve) => setTimeout(resolve, 50)); const refreshed = await redis.get(key); if (refreshed) return JSON.parse(refreshed);

// Do not wait indefinitely: use the database as the final fallback. return prisma.product.findUnique({ where: { id } });

The lock needs an expiration so a crashed worker cannot block refreshes forever. This example is intentionally bounded: one short wait, one cache retry, then the source of truth. For high-contention workloads, use a carefully tested distributed-lock implementation or serve a stale value while a single worker refreshes it.

Handle Missing Records Without Hammering the Database

Requests for nonexistent IDs can be surprisingly expensive, especially during scans or bot traffic. Negative caching stores a short sentinel:

const NOT_FOUND = '__not_found__';

const cached = await redis.get(key); if (cached === NOT_FOUND) return res.status(404).json({ error: 'Not found' });

const product = await prisma.product.findUnique({ where: { id } }); if (!product) { await redis.set(key, NOT_FOUND, { EX: 30 }); return res.status(404).json({ error: 'Not found' }); }

Use a much shorter TTL for negative results. Invalidate the key when that ID is created, or a newly created record may appear missing until the sentinel expires.

Test the Behavior, Not Just the Happy Path

Use the X-Cache response header during development and integration tests:

# First request should be a miss
curl -i http://localhost:3000/api/products/42

Second request should be a hit

curl -i http://localhost:3000/api/products/42

After an update, the next read should miss and refill

curl -i -X PATCH http://localhost:3000/api/products/42
-H 'Content-Type: application/json'
-d '{"name":"Mechanical Keyboard","price":129}'

Your automated tests should cover a hit, miss, malformed cached JSON, Redis timeout, database error, invalidation after a write, and concurrent misses. Stop Redis during a local test and confirm that the endpoint still reads from PostgreSQL instead of hanging.

Production Monitoring and Security

Track cache hit ratio, Redis command latency, fallback count, memory usage, evictions, error rate, and database query volume. A high hit ratio is not automatically success: cached endpoints still need acceptable tail latency and correct invalidation.

Apply these production safeguards:

  • Keep Redis off the public internet and restrict network access to application hosts.
  • Use TLS and credentials or ACLs supplied by your Redis deployment.
  • Set an explicit memory limit and eviction policy that matches disposable cache data.
  • Use short connection and command timeouts so cache failures do not become API failures.
  • Do not cache secrets or sensitive responses unless access boundaries are encoded and reviewed.
  • Cap payload size. Large JSON objects increase network, memory, serialization, and garbage-collection costs.
  • Add structured logs without recording cached personal data.

Troubleshooting Common Redis Cache Problems

The endpoint is slower after adding Redis

Check network distance, connection reuse, serialization cost, and hit ratio. A Redis instance in another region can be slower than a local indexed database query. Do not create a new client for each request.

Users see old data after an update

Confirm every write path deletes all affected keys, including list and aggregate keys. Keep TTLs bounded, and add an outbox-driven retry when a brief stale window is unacceptable.

Database traffic spikes at regular intervals

Look for synchronized TTLs. Add jitter, pre-warm only genuinely hot keys, and protect high-demand misses with request coalescing or a short lock.

Memory grows until keys are evicted

Inspect key cardinality and average value size. Verify that cache entries have expirations, use versioned bounded key spaces, and configure a memory ceiling rather than allowing Redis to compete with the operating system.

Production Checklist

  • The database remains the source of truth.
  • Keys include environment, resource type, identifier, and schema version.
  • Every cached value has a deliberate TTL.
  • Database writes complete before invalidation.
  • Redis errors fall back quickly instead of hanging requests.
  • Hot-key expiration is protected with jitter and bounded stampede control.
  • Tests simulate hits, misses, stale data, malformed values, and outages.
  • Dashboards show hit ratio, latency, errors, evictions, and database fallback load.
  • Production connections use private networking, authentication, and TLS.

Final Takeaway

A reliable Redis cache is intentionally boring: stable keys, short bounded TTLs, delete-after-write invalidation, fast failure, and observable behavior. Start with one measured endpoint, prove that cache hits reduce latency or database load, then expand only where the operational tradeoff is worthwhile.

Official Sources