Add Redis Caching to Your Product API

Add Redis caching to the NextWork Product Catalog API.

Introduction

30 Second Summary

Think about the last time you refreshed a page and it loaded instantly the second time around. Behind the scenes, that speed boost usually comes from caching, where frequently-read data is stored in memory so it can be served without hitting the database again.

In this project, you will add a Redis caching layer to the Product Catalog API from siredave/nextwork_product_catalog. You will preserve its Express controller-service structure while accelerating filtered and paginated product reads.

What You'll Build

You will request /api/v1/products?page=1&limit=5 twice and watch the second response arrive from Redis with the same data and meta fields as the original MongoDB response.

By the end of this project, you'll have:

  • A query-aware product cache that keeps pagination, category filters, sorting, and the API's response envelope intact.
  • Visible cache observability through X-Cache and X-Response-Time headers on requests to /api/v1/products.
  • Resilient reads that continue through the existing product service when Redis is unavailable.
  • Secret Mission: Cache individual product lookups with a 60-second TTL and invalidate them after updates or deletion.

Are there any prerequisites?

This project continues the Product Catalog API repository. Have its MongoDB connection and existing environment variables working locally with Node.js 20.19.0 or newer.

Before We Start

Before We Start...

Before you change the Product Catalog API, lock in why its repeated product reads need a cache and which existing behavior you must preserve.

Set Up Redis Cloud and Connect

The Product Catalog API already has authentication, validation, image uploads, and MongoDB-backed product routes. This step prepares that exact repository for a Redis connection without changing its API behavior.

You will confirm the repository runtime, provision Redis Cloud, and connect it through the existing src/server.js startup flow.

In this step, get ready to:
  • Verify the repository with Node.js 20.19.0 or newer.
  • Create a Redis Cloud database and store its TLS URL safely.
  • Connect Redis through the existing server startup function.
Verify the repository runtime
  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Terminal on macOS or PowerShell on Windows, then press Enter.
  • Move into your existing repository by running this command from the folder that contains it:
cd nextwork_product_catalog
  • Check the runtime used by this repository by running:
node --version

✔️ I see version 20.19.0 or higher

Your runtime supports the repository's Mongoose 9 dependency and Node Redis 6. Continue with the dependency installation.

ⓧ I see an older version

Upgrade Node.js before continuing. Mongoose 9 requires Node.js 20.19.0 or newer, so an older runtime can fail before Redis code runs.

Install a supported Node.js release, reopen your terminal, and run the version check again.

ⓧ Command not found

Install Node.js 20.19.0 or newer from the official download page. Reopen your terminal after installation so the node command is available.

  • Install the repository packages plus its missing direct middleware dependencies and Redis by running:
npm install
npm install compression cookie-parser redis

Why install three packages?

The repository's src/app.js imports compression and cookie-parser, but they are not listed as direct dependencies. Installing them here makes a fresh checkout reproducible.

Create Redis Cloud credentials

Redis Cloud provides the remote in-memory store for this project. The free database is enough for the product responses you cache here.

  • Open the Redis Cloud signup page and sign in.
  • Click New database.
  • Select Try 30 MB for free.
  • Choose a cloud vendor and a region near you.
  • Click Create database.

Provisioning can take about a minute. The active status confirms the database is ready for connections.

  • Open the database's Security page.
  • Open Default user.
  • Click Configure.
  • Reveal the password and store it in your password manager.
  • Open Connect and copy the public endpoint.
  • Add the TLS connection URL to the existing .env file in nextwork_product_catalog using this format:
REDIS_URL=rediss://default:your-password-here@your-host:your-port

Protect your Redis password

The password is a live credential. Keep .env out of Git and never include the revealed value in a screenshot.

Connect Redis during startup
  • Create src/config/redis.js inside the repository, then add this connection module:
const { createClient } = require("redis");

// Build one shared client from the protected environment value.
const redisClient = createClient({ url: process.env.REDIS_URL });

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

// Let the API continue if the optional cache is unavailable.
const connectRedis = async () => {
  try {
    if (!redisClient.isOpen) await redisClient.connect();
    console.log("Redis connected");
  } catch (error) {
    console.warn("Redis unavailable:", error.message);
  }
};

module.exports = { redisClient, connectRedis };

What does this code do?

  • createClient() reads the TLS URL from REDIS_URL.
  • isOpen prevents a duplicate connection attempt during development.
  • The catch block keeps Redis optional, so MongoDB remains the source of truth.
  • Replace src/server.js with this startup flow:
const app = require("./app");
const connectDB = require("./config/db");
const { connectRedis } = require("./config/redis");

const PORT = process.env.PORT || 5000;

async function startServer() {
  try {
    // MongoDB is required, while Redis remains optional.
    await connectDB();
    await connectRedis();

    app.listen(PORT, () => {
      console.log(`Server is running on port ${PORT}`);
    });
  } catch (error) {
    console.error("Failed to start the API:", error);
    process.exit(1);
  }
}

startServer();
  • Save src/config/redis.js and src/server.js.
  • Start the development server by running:
npm run dev

You should see MongoDB Connected, Redis connected, and Server is running on port 5000.

Redis does not connect?

  • Confirm REDIS_URL starts with rediss://.
  • Confirm the password and endpoint contain no spaces.
  • Help me diagnose the Redis connection.

Your existing Product Catalog API now starts with both data stores connected. Next, you will measure its current response time before caching changes the result.

Add Response Time Tracking and Cache Headers

Your Product Catalog API now starts with MongoDB and Redis connected. Before Redis serves any product data, you need a baseline that shows how long the existing service path takes.

You will add one Express middleware in src/middleware/responseTime.js. It marks uncached requests as BYPASS and records the time before the JSON response leaves the API.

In this step, get ready to:
  • Create response-time and cache-status middleware.
  • Register the middleware before the repository's routes.
  • Measure the existing paginated product response.
Create the response middleware
  • Create src/middleware/responseTime.js and add this middleware:
// Measure each request before its JSON response is sent.
const responseTime = (req, res, next) => {
  const startedAt = process.hrtime.bigint();

  // Routes can replace BYPASS with HIT or MISS.
  res.set("X-Cache", "BYPASS");

  const sendJson = res.json.bind(res);
  res.json = (body) => {
    const elapsed = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
    res.set("X-Response-Time", `${elapsed.toFixed(2)}ms`);
    return sendJson(body);
  };

  next();
};

module.exports = responseTime;

What does this code do?

  • process.hrtime.bigint() measures short operations more precisely than a wall clock.
  • BYPASS identifies routes that have not attempted a cache lookup.
  • The res.json wrapper sets the timing header before Express sends the response.
  • Save src/middleware/responseTime.js.
  • Confirm the new file exists by running:
ls src/middleware/responseTime.js

You should see the path printed without a file-not-found error.

Module not found?

  • Confirm the filename uses the same capitalization as the import.
  • Confirm the file sits inside src/middleware.
  • Help me fix the middleware import.
Register and test the middleware
  • Open src/app.js and add this import beside the other middleware imports:
const responseTime = require("./middleware/responseTime");
  • Add this line after app.use(morgan("dev")) and before the health and product routes:
app.use(responseTime);

Why register it before the routes?

Express runs middleware in registration order. This position starts the timer before /api/v1/health or /api/v1/products handles the request.

  • Save src/app.js.
  • Restart the development server by running:
npm run dev

Before you send the request, which cache status should appear when no product caching exists yet?

  • Request the first page of products and include response headers by running:
curl -i "http://localhost:5000/api/v1/products?page=1&limit=5"

You should receive HTTP 200 with the repository's success, data, and meta fields. The headers show X-Cache: BYPASS and an X-Response-Time value in milliseconds.

  • Run the repository's existing tests to check that the middleware did not change route behavior:
npm test

You should see the test run finish without failures. That confirms the response envelope and existing error paths still work.

Headers are missing?

  • Confirm app.use(responseTime) appears before the route registrations.
  • Confirm you restarted the server after saving.
  • Help me find the missing headers.

Your baseline now exposes both the API contract and its uncached timing. Next, you will make the same query return from Redis without changing either one.

Implement Cache-Aside on GET /products

Implement Cache-Aside on Product Lists

Your baseline proves that every list request still reaches MongoDB. The next change keeps the repository's filters, pagination, and response envelope while giving repeated queries a faster path.

You will implement the Cache-Aside pattern in the existing getAllProducts controller. Each distinct query gets its own Redis key, so page 1 can never return the cached data for page 2.

In this step, get ready to:
  • Create query-aware cache keys for product lists.
  • Cache the complete product response in the existing controller.
  • Prove repeated and changed queries produce the correct cache status.
Build query-aware cache keys

The repository accepts category, sort, page, and limit on its list route. A cache key must include all four values to identify the exact result.

  • Create src/services/cache.service.js and add the key builder:
const CACHE_VERSION = "v1";
const LIST_TTL_SECONDS = 300;

// Normalize every input that changes the service result.
const buildProductListKey = (query = {}) => {
  const normalized = {
    category: String(query.category || "").trim(),
    sort: String(query.sort || "-createdAt"),
    page: String(query.page || 1),
    limit: String(query.limit || 10),
  };

  // Encode the query so the Redis key stays compact and deterministic.
  const queryKey = Buffer.from(JSON.stringify(normalized)).toString("base64url");
  return `${CACHE_VERSION}:products:list:${queryKey}`;
};

module.exports = {
  CACHE_VERSION,
  LIST_TTL_SECONDS,
  buildProductListKey,
};

What does this code do?

  • Default values mirror product.service.js so an omitted query parameter gets a stable key.
  • Base64 URL encoding prevents spaces and punctuation from making the key difficult to inspect.
  • CACHE_VERSION gives future deployments a clean namespace.
  • Save src/services/cache.service.js.
  • Confirm the module loads by running:
node -e "console.log(require('./src/services/cache.service').buildProductListKey({ page: 1, limit: 5 }))"

You should see a key beginning with v1:products:list:.

Key builder fails?

  • Confirm the filename is cache.service.js inside src/services.
  • Confirm every string in the module uses matching quotes.
  • Help me debug the cache key builder.
Cache the controller response
  • Open src/controllers/product.controller.js.
  • Add these imports below the existing product service import:
const { redisClient } = require("../config/redis");
const {
  LIST_TTL_SECONDS,
  buildProductListKey,
} = require("../services/cache.service");
  • Find the existing getAllProducts function and replace it with this cache-aside version:
const getAllProducts = async (req, res, next) => {
  const cacheKey = buildProductListKey(req.query);

  try {
    // Return the exact saved API payload on a cache hit.
    if (redisClient.isReady) {
      const cached = await redisClient.get(cacheKey);
      if (cached) {
        res.set("X-Cache", "HIT");
        return res.status(200).json(JSON.parse(cached));
      }
    }

    // Keep the repository's service as the source of product data.
    const result = await productService.getAllProducts(req.query);
    const payload = {
      success: true,
      data: result.products,
      meta: {
        total: result.total, page: result.page,
        limit: result.limit, pages: result.pages,
      },
    };

    if (redisClient.isReady) {
      await redisClient.set(cacheKey, JSON.stringify(payload), { EX: LIST_TTL_SECONDS });
    }
    res.set("X-Cache", "MISS");
    return res.status(200).json(payload);
  } catch (error) {
    return next(error);
  }
};

How does this preserve the repository?

  • A hit returns the stored success, data, and meta object.
  • A miss calls the unchanged product service with req.query.
  • EX: LIST_TTL_SECONDS removes abandoned query variants after five minutes.
  • Save src/controllers/product.controller.js.
  • Restart the API by running:
npm run dev

Before you make the first request, predict which cache status appears when this exact query has no saved response.

  • Send the same query twice by running this command twice:
curl -i "http://localhost:5000/api/v1/products?page=1&limit=5"

The first response shows X-Cache: MISS. The second shows X-Cache: HIT with the same body and usually a lower response time.

  • Change the page number to prove the cache keeps query results separate:
curl -i "http://localhost:5000/api/v1/products?page=2&limit=5"

You should see X-Cache: MISS because page 2 has a different cache key. A second page 2 request then becomes a hit.

Second request still misses?

  • Confirm the imports use ../config/redis and ../services/cache.service.
  • Confirm the cached value contains the whole payload rather than only result.products.
  • Help me find why the cache never hits.

That repeated Product Catalog query now skips MongoDB without changing its response shape. Next, you will expose the stale-data bug that appears after a product changes.

Fix Stale Data with Cache Invalidation

Your Product Catalog list now returns query-specific cache hits. That speed creates a consistency problem when an authenticated write changes MongoDB but Redis still holds older list responses.

You will trigger that stale response before fixing it. Then you will invalidate every cached list variant after the repository's existing create, update, and delete service calls succeed.

In this step, get ready to:
  • Observe a newly created product missing from a cached list.
  • Add safe invalidation for every product-list query key.
  • Verify authenticated writes refresh the next public read.
Expose the stale-list bug

The repository protects product mutations but leaves product reads public. Use its Postman collection so the access token stays outside screenshots and shell history.

  • Send List products from the repository's Postman collection twice with page=1 and limit=5.
  • Confirm the second list response shows X-Cache: HIT.
  • Run Login and save the returned access token in the collection variable.
  • Send Create product with Cache Test Product as the name.
  • Confirm the create response returns HTTP 201 and success: true.

Before you list page 1 again, do you think the cached response already knows about the new MongoDB document?

  • Send the same List products request again.
  • Check the X-Cache header and search the response body for Cache Test Product.

You will see a cache hit with the old list. The create service saved the product correctly, but no code removed the earlier Redis response.

Why did the new product disappear?

A stale cache entry no longer matches MongoDB, which remains the source of truth. Query-aware caching creates several possible list keys, so deleting one fixed key cannot refresh every page and filter.

Invalidate every list variant
  • Open src/services/cache.service.js.
  • Add the Redis client import at the top of the file:
const { redisClient } = require("../config/redis");
  • Add this invalidation helper below buildProductListKey.
// Remove every cached pagination, filter, and sort variant.
const invalidateProductLists = async () => {
  if (!redisClient.isReady) return;

  try {
    const pattern = `${CACHE_VERSION}:products:list:*`;
    for await (const keys of redisClient.scanIterator({
      MATCH: pattern,
      COUNT: 100,
    })) {
      if (keys.length > 0) await redisClient.del(keys);
    }
  } catch (error) {
    // A cache cleanup failure must not undo a MongoDB write.
    console.warn("Product cache invalidation failed:", error.message);
  }
};

Why scan for list keys?

  • SCAN walks matching keys without blocking Redis like a broad KEYS command can.
  • The pattern matches every cached list query in the current deployment namespace.
  • The catch preserves the completed MongoDB write if Redis is unavailable.
  • Add invalidateProductLists to the module's export object.
module.exports = {
  CACHE_VERSION,
  LIST_TTL_SECONDS,
  buildProductListKey,
  invalidateProductLists,
};
  • Save src/services/cache.service.js.
  • Run the repository tests before wiring the helper into writes:
npm test

You should see the existing tests pass. The new helper has not changed a route yet.

Cached lists remain?

  • Confirm scanIterator uses MATCH with the full versioned prefix.
  • Confirm the Redis error stays inside the helper's catch block.
  • Help me debug list invalidation.
Connect invalidation to successful writes
  • Open src/controllers/product.controller.js and include the invalidation helper in the existing cache service import:
const {
  LIST_TTL_SECONDS,
  buildProductListKey,
  invalidateProductLists,
} = require("../services/cache.service");
  • In createProduct, add this call after productService.createProduct(data) succeeds and before the HTTP 201 response:
// Product data changed, so every cached list is stale.
await invalidateProductLists();
  • Add the same invalidation call after the successful productService.updateProduct() call.
  • Add the same invalidation call after the successful productService.deleteProduct() call.
  • Save src/controllers/product.controller.js.
  • Restart the API by running:
npm run dev
  • Prime List products until it shows X-Cache: HIT.
  • Create another product through the authenticated Postman request.
  • Send List products again.

The next list response shows X-Cache: MISS and includes the new product. The request after that becomes a hit again.

  • Run an authenticated update and repeat the list request.
  • Run an authenticated delete and repeat the list request.
  • Run the full test suite after all three controller edits:
npm test

Each successful mutation makes the next product-list request miss once, then later reads hit the refreshed value. The repository tests should still pass.

Your cached pages now follow every successful product mutation. Next, you will make sure a Redis failure never breaks the repository's MongoDB-backed API.

Add Graceful Degradation

Your write endpoints now clear every stale list key. Redis is still an accelerator, so a cache outage must never replace a valid product response with an HTTP 500 error.

You will centralize safe cache reads and writes, then let src/server.js start without waiting forever for Redis. MongoDB remains required because it owns the product data.

In this step, get ready to:
  • Add guarded Redis read and write helpers.
  • Make the Redis startup connection non-blocking.
  • Prove product reads survive an unavailable cache.
Guard every cache operation

A readiness check avoids commands when the client is disconnected. A try-catch handles a connection that fails after the check but before the command completes.

  • Open src/services/cache.service.js and add these helpers above the export object:
// Return null when Redis cannot safely serve a value.
const readCache = async (key) => {
  if (!redisClient.isReady) return null;
  try {
    return await redisClient.get(key);
  } catch (error) {
    console.warn("Redis GET skipped:", error.message);
    return null;
  }
};

// Treat a failed cache write as optional work.
const writeCache = async (key, value, ttlSeconds) => {
  if (!redisClient.isReady) return false;
  try {
    await redisClient.set(key, value, { EX: ttlSeconds });
    return true;
  } catch (error) {
    console.warn("Redis SET skipped:", error.message);
    return false;
  }
};

What is the dual guard?

  • isReady prevents a command when no usable socket exists.
  • The catch blocks convert cache failures into misses instead of API failures.
  • The Boolean write result supports future monitoring without affecting the response.
  • Export readCache and writeCache from the same module.
  • Open src/controllers/product.controller.js and remove the direct redisClient import.
  • Add both helpers to the existing cache service import.
const {
  LIST_TTL_SECONDS,
  buildProductListKey,
  invalidateProductLists,
  readCache,
  writeCache,
} = require("../services/cache.service");
  • In getAllProducts, replace the direct Redis GET block with:
// A null value covers both a real miss and an unavailable cache.
const cached = await readCache(cacheKey);
if (cached) {
  res.set("X-Cache", "HIT");
  return res.status(200).json(JSON.parse(cached));
}
  • Replace the direct Redis SET block with:
// Cache failure does not block the MongoDB response.
await writeCache(
  cacheKey,
  JSON.stringify(payload),
  LIST_TTL_SECONDS,
);
  • Save src/services/cache.service.js and src/controllers/product.controller.js.
  • Run the test suite:
npm test

You should see the existing controller and route tests complete without failures.

Redis errors still reach Express?

  • Confirm the controller no longer calls redisClient.get() or redisClient.set() directly.
  • Confirm both helpers return a fallback value inside their catch blocks.
  • Help me find the unguarded Redis command.
Let the API start without Redis
  • Open src/config/redis.js and replace connectRedis with this non-blocking version:
// Start connecting in the background so Redis cannot block Express.
const connectRedis = () => {
  if (redisClient.isOpen) return;

  redisClient
    .connect()
    .then(() => console.log("Redis connected"))
    .catch((error) => {
      console.warn("Redis unavailable:", error.message);
    });
};
  • Save src/config/redis.js.
  • Copy your real REDIS_URL into your password manager before testing the outage.
  • Replace the value in .env temporarily with:
REDIS_URL=redis://127.0.0.1:6390

Changing this credential-like setting is deliberate and temporary. Your real Redis password stays safe in your password manager, and you restore it before leaving this step.

  • Restart the API by running:
npm run dev

Before the logs settle, predict whether Express reaches port 5000 while the Redis connection is failing.

You should see Redis connection errors alongside Server is running on port 5000. The cache can fail without blocking the API.

  • Request the product list while Redis remains unavailable:
curl -i "http://localhost:5000/api/v1/products?page=1&limit=5"

You should receive HTTP 200 with fresh MongoDB data and X-Cache: MISS.

  • Restore the real REDIS_URL in .env.
  • Restart the API and confirm the second identical product request becomes a cache hit.

Your Product Catalog API now treats Redis as an optional speed layer instead of a single point of failure. Next, you will make its existing health endpoint report that distinction.

Build a Health Check Endpoint

Enhance the Existing Health Endpoint

Your API keeps serving products when Redis is unavailable. Its current /api/v1/health route still returns the same generic message, so monitoring cannot tell a healthy cache from a degraded one.

You will enhance that existing route in src/app.js. MongoDB determines whether the API can serve product data, while Redis contributes a separate optional dependency status.

In this step, get ready to:
  • Report MongoDB and Redis readiness from the existing health route.
  • Distinguish healthy, degraded, and unavailable states.
  • Verify the route with Redis connected and disconnected.
Enhance the existing health route
  • Open src/app.js and add these imports beside the existing package and configuration imports:
const mongoose = require("mongoose");
const { redisClient } = require("./config/redis");
  • Find the existing app.get("/api/v1/health") route.
  • Replace the complete route with this dependency-aware version:
// Report required and optional dependency health separately.
app.get("/api/v1/health", (req, res) => {
  const mongodb = mongoose.connection.readyState === 1
    ? "connected"
    : "disconnected";
  const redis = redisClient.isReady
    ? "connected"
    : "disconnected";

  // MongoDB owns product data, so only its failure makes the API unavailable.
  const status = mongodb === "connected"
    ? redis === "connected" ? "ok" : "degraded"
    : "unavailable";
  const statusCode = mongodb === "connected" ? 200 : 503;

  res.status(statusCode).json({
    success: statusCode === 200,
    status,
    services: { mongodb, redis },
  });
});

Why is degraded still HTTP 200?

  • mongoose.connection.readyState === 1 confirms the required database connection is ready.
  • redisClient.isReady reports whether cache commands can run.
  • degraded still returns HTTP 200 because product requests can fall back to MongoDB.
  • Save src/app.js.
  • Run the test suite:
npm test

You should see the existing route tests finish without failures. If a test expects the old message-only health response, update that assertion to match the new status object.

Still seeing the old message?

  • Confirm you replaced the existing health route instead of adding a second route.
  • Confirm the imports use ./config/redis from src/app.js.
  • Help me find the old health response.
Verify healthy and degraded states
  • Start the API with the real Redis URL by running:
npm run dev

Before you check the route, predict the overall status when both dependencies are connected.

  • Request the health endpoint:
curl -i http://localhost:5000/api/v1/health

You should receive HTTP 200 with "status":"ok" and both services marked connected.

  • Store the real REDIS_URL safely and replace it temporarily with redis://127.0.0.1:6390.
  • Restart the API.
  • Request the health endpoint again.

This time you should receive HTTP 200 with "status":"degraded", MongoDB connected, and Redis disconnected. That response tells monitoring the API works with reduced performance.

  • Restore the real REDIS_URL and restart the API.

Health status looks wrong?

  • Confirm the request uses /api/v1/health rather than /health.
  • Confirm MongoDB connected before testing the Redis-only degraded state.
  • Help me debug the dependency status.

Your existing health URL now exposes the API's real dependency state without treating an optional cache as the database. Next, you will stop a long Redis outage from producing endless reconnect noise.

Add Connection Retry with Exponential Backoff

Your enhanced health route reveals when Redis is disconnected. The background client still uses its default reconnect loop, which can keep retrying and filling logs throughout a long outage.

You will give the shared Redis client a capped exponential backoff strategy. Short failures receive several recovery attempts, while a prolonged failure leaves the API in its working MongoDB-only mode.

In this step, get ready to:
  • Add exponential delay and random jitter to reconnection attempts.
  • Stop retrying after five failed reconnections.
  • Verify the retry sequence and degraded health response.
Configure the reconnect strategy

Node Redis calls reconnectStrategy whenever an established or attempted socket connection fails. Returning a number schedules another attempt, while returning false stops the loop.

  • Open src/config/redis.js and find the existing createClient call:
const redisClient = createClient({ url: process.env.REDIS_URL });
  • Replace that line with this configured client:
const MAX_RETRIES = 5;

// Retry transient failures without looping forever.
const redisClient = createClient({
  url: process.env.REDIS_URL,
  socket: {
    reconnectStrategy: (retries) => {
      if (retries >= MAX_RETRIES) {
        console.warn("Redis retry limit reached");
        return false;
      }

      // Double the delay and spread instances with random jitter.
      const delay = Math.min(2 ** retries * 50, 2000);
      const jitter = Math.floor(Math.random() * 200);
      console.warn(
        `Redis retry ${retries + 1}/${MAX_RETRIES}`,
      );
      return delay + jitter;
    },
  },
});

How does the strategy behave?

  • The delay grows from tens of milliseconds toward a two-second ceiling.
  • Jitter prevents several API instances from reconnecting at the same instant.
  • MAX_RETRIES turns a prolonged outage into a stable degraded state.
  • Save src/config/redis.js.
  • Run the repository tests:
npm test

You should see the suite finish without failures because the client configuration does not alter the API contract.

No retry messages?

  • Confirm socket sits inside the object passed to createClient().
  • Confirm the option is spelled reconnectStrategy.
  • Help me debug the retry configuration.
Observe the capped retries
  • Store the real REDIS_URL safely before changing .env.
  • Set the temporary unavailable endpoint:
REDIS_URL=redis://127.0.0.1:6390
  • Restart the API by running:
npm run dev

The API starts on port 5000 while the terminal logs retries 1 through 5. After the final attempt, you should see Redis retry limit reached.

Before checking health, predict whether the API reports unavailable or degraded after Redis stops retrying.

  • Request the health endpoint after the final retry:
curl -i http://localhost:5000/api/v1/health

You should receive HTTP 200 with "status":"degraded" and Redis marked disconnected.

  • Restore the real REDIS_URL in .env.
  • Restart the API and request the health endpoint again.

You should see "status":"ok" with Redis connected. The client recovers immediately when the endpoint is valid.

Your cache connection now recovers from brief failures and settles cleanly during long ones. Next, you will separate cache data created by different application releases.

Add Key Versioning for Safe Deployments

Your Redis client now stops retrying after a controlled backoff. A deployment can still read cache entries written by older code, even when the new response shape has changed.

You already placed v1 at the start of each product-list key. You will move that version into .env so a deployment can switch namespaces without flushing Redis.

In this step, get ready to:
  • Read the cache namespace from an environment variable.
  • Verify the current namespace produces hits.
  • Bump the version and observe a clean cache miss.
Make the cache version configurable
  • Open src/services/cache.service.js and find the current constant:
const CACHE_VERSION = "v1";
  • Replace it with this environment-backed value:
// Change the namespace when a deployment changes cached data shapes.
const CACHE_VERSION = process.env.CACHE_VERSION || "v1";
  • Open the repository's existing .env file and add:
CACHE_VERSION=v1

What is key versioning?

A key such as v1:products:list:... belongs to one cache schema. Changing the environment value makes older keys invisible to new code, and their five-minute TTL removes them later.

  • Save src/services/cache.service.js and .env.
  • Restart the API.
  • Send the same product-list request twice:
curl -i "http://localhost:5000/api/v1/products?page=1&limit=5"

You should see a miss followed by a hit under the v1 namespace.

Version value is ignored?

  • Confirm dotenv loads before the cache service is required.
  • Confirm CACHE_VERSION contains no spaces.
  • Help me debug the cache namespace.
Simulate a deployment version bump
  • Change the environment value to:
CACHE_VERSION=v2
  • Save .env.
  • Restart the API so Node.js reloads the environment.

Before you repeat the request, predict whether the old v1 entry can satisfy a lookup for a v2 key.

  • Send the same product-list request again:
curl -i "http://localhost:5000/api/v1/products?page=1&limit=5"

You should see X-Cache: MISS even though the v1 value still exists. A second request becomes a HIT in the new namespace.

  • Leave CACHE_VERSION=v2 for the remaining steps.
  • Run the repository tests:
npm test

You should see the tests pass without deleting or flushing any Redis data.

Your deployments can now abandon incompatible cache entries with one environment change. Next, you will stop concurrent misses from rebuilding the same list several times.

Protect Against Cache Stampedes

Your cache keys are versioned and your connections retry intelligently. But picture this: your product list cache expires, and 50 requests hit your API in the same second. Without protection, all 50 simultaneously query MongoDB and all 50 write to Redis. That's a cache stampede.

A mutex lock prevents this. The first request to find an empty cache acquires a short-lived lock in Redis, rebuilds the cache, then releases it. All other requests wait briefly and then read the freshly populated cache instead of dog-piling MongoDB.

In this step, get ready to:
  • Implement a Redis-based mutex using SET with the NX and EX options.
  • Add stampede protection to the getProducts controller.
  • Verify that concurrent requests don't all hit MongoDB simultaneously.
Understand the mutex pattern

What is a cache stampede?

A cache stampede (also called a thundering herd) happens when a popular cache key expires and many concurrent requests all try to rebuild it at once. Each request queries the database, wastes work, and can overload your backend. The mutex pattern ensures only one request rebuilds while others wait.

The lock uses Redis SET key value NX EX: NX means "only set if the key doesn't exist" (acquire the lock) and EX sets a timeout so the lock auto-releases if the holder crashes.

Add the mutex helper function
  • Open controllers/productController.js and add this helper function after your constants (CACHE_VERSION), before getProducts:
// Acquire a short-lived mutex lock in Redis
const acquireLock = async (lockKey, ttlSeconds) => {
  try {
    // NX = only set if key doesn't exist (atomic lock)
    // EX = auto-expire after ttlSeconds (prevents deadlocks)
    const result = await redisClient.set(lockKey, 'locked', {
      NX: true,
      EX: ttlSeconds
    });
    return result === 'OK';
  } catch (err) {
    return false;
  }
};

What does this code do?

  • SET ... NX is atomic: if two requests race to acquire the lock, only one gets 'OK' back. The other gets null.
  • EX prevents deadlocks. If the lock holder crashes mid-rebuild, the lock expires after the TTL and another request can take over.
  • The function returns true if the lock was acquired, false if someone else holds it or Redis is down.
  • Save controllers/productController.js.
Add stampede protection to getProducts

Now you'll modify the cache-miss path in getProducts. When the cache is empty, the function tries to acquire a lock before querying MongoDB. If it can't get the lock, it waits briefly and retries the cache read (because another request is already rebuilding it).

  • Replace your entire getProducts function with this version:
const getProducts = async (req, res) => {
  const cacheKey = `${CACHE_VERSION}:products:all`;
  const lockKey = `${cacheKey}:lock`;

  try {
    // Try cache first
    try {
      if (redisClient.isReady) {
        const cached = await redisClient.get(cacheKey);
        if (cached) {
          res.set('X-Cache', 'HIT');
          return res.json(JSON.parse(cached));
        }
      }
    } catch (cacheErr) {
      console.log('Redis GET failed:', cacheErr.message);
    }
  • Now add the stampede protection logic that follows the cache-miss check. Place this immediately after the closing brace of the cache try-catch above:
    // Cache miss - try to acquire rebuild lock
    let gotLock = false;
    if (redisClient.isReady) {
      gotLock = await acquireLock(lockKey, 5);

      if (!gotLock) {
        // Another request is rebuilding, wait and retry cache
        await new Promise(r => setTimeout(r, 100));
        try {
          const cached = await redisClient.get(cacheKey);
          if (cached) {
            res.set('X-Cache', 'HIT');
            return res.json(JSON.parse(cached));
          }
        } catch (retryErr) {
          console.log('Redis retry GET failed:', retryErr.message);
        }
      }
    }
  • Finally, add the MongoDB query and cache-write portion that completes the function:
    // Query MongoDB (either we got the lock, or retry also missed)
    const products = await Product.find({});

    // Cache the result
    try {
      if (redisClient.isReady) {
        await redisClient.set(cacheKey, JSON.stringify(products));
        // Release lock by deleting it
        if (gotLock) {
          await redisClient.del(lockKey);
        }
      }
    } catch (cacheErr) {
      console.log('Redis SET failed:', cacheErr.message);
    }

    res.set('X-Cache', 'MISS');
    res.json(products);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
};

How does the stampede protection flow work?

  • Request A finds an empty cache and acquires the lock. It queries MongoDB and writes to the cache.
  • Requests B, C, and D also find an empty cache but fail to acquire the lock (NX returns null). They wait 100ms, then retry the cache GET.
  • By the time B, C, D retry, Request A has already populated the cache. They get a HIT without touching MongoDB.
  • If the lock holder crashes, the 5-second EX ensures the lock self-destructs and the next request can rebuild.
  • Save controllers/productController.js.
Test stampede protection
  • Restart your server.
  • Hit GET /products to confirm the basic flow still works (MISS then HIT on second request).

To see the lock in action, you need to invalidate the cache and send multiple requests at once. Run this command to fire 5 concurrent requests:

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/products & curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/products & curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/products & curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/products & curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/products & wait

All 5 should return 200. Check your server console. Without stampede protection, you'd see 5 MongoDB queries logged. With it, you should see only 1 or 2 (the lock holder plus possibly one request that retried and still missed).

Seeing multiple MongoDB queries despite the lock?

  • The 100ms wait might not be long enough if your MongoDB query takes longer. This is expected in a local dev environment with a remote MongoDB. In production, the query is typically faster than the wait.
  • Confirm that acquireLock uses NX: true (not just NX). The boolean form is required in node-redis.

Why is my stampede lock not preventing multiple MongoDB queries?

Your API now handles concurrent cache misses without overwhelming MongoDB. Next up, you'll add cache warmup so the very first request after a server restart gets a cache HIT.

Warm the Cache on Server Startup

Your stampede lock protects against concurrent rebuilds. But every time your server restarts — from a deployment, a crash recovery, or scaling up a new instance — the cache is empty. The very first user to hit /products pays the full MongoDB query cost while everyone else waits.

Cache warmup eliminates that cold-start penalty by pre-populating key cache entries the moment the server starts. The first real user gets a cache HIT immediately.

In this step, get ready to:
  • Create a warmCache function that pre-loads the product list into Redis on startup.
  • Call it after both MongoDB and Redis connections are established.
  • Verify that the first GET /products after a restart returns X-Cache: HIT.
Create the cache warmup function
  • Open controllers/productController.js and add this function at the bottom of the file, before the module.exports line:
// Pre-populate cache on server startup
const warmCache = async () => {
  try {
    if (!redisClient.isReady) {
      console.log('Cache warmup skipped: Redis not connected');
      return;
    }

    const cacheKey = `${CACHE_VERSION}:products:all`;

    // Check if cache already has data (e.g. from another instance)
    const existing = await redisClient.get(cacheKey);
    if (existing) {
      console.log('Cache warmup skipped: cache already populated');
      return;
    }

    // Load products from MongoDB and cache them
    const products = await Product.find({});
    await redisClient.set(cacheKey, JSON.stringify(products));
    console.log(`Cache warmup complete: ${products.length} products cached`);
  } catch (err) {
    console.log('Cache warmup failed:', err.message);
    console.log('Server will continue - first request will populate cache');
  }
};

What does this code do?

  • The function checks if Redis is connected. If not, it skips warmup silently (graceful degradation carries on).
  • It checks if the cache already has data. In a multi-instance deployment, another server might have already warmed the cache.
  • If the cache is empty, it queries MongoDB and writes the result to Redis. The first user request will find the data already cached.
  • If anything fails, the error is logged but the server continues. Warmup is best-effort, not critical.
  • Update the module.exports line to include warmCache:
module.exports = { getProducts, createProduct, updateProduct, deleteProduct, warmCache };
  • Save controllers/productController.js.
Call warmCache on server startup
  • Open server.js and find where you call connectRedis() and start the server. It likely looks something like this:
const startServer = async () => {
  await connectDB();
  await connectRedis();

  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
};
  • Add the warmCache import at the top of server.js:
const { warmCache } = require('./controllers/productController');
  • Call warmCache() after both connections are established but before (or just after) the server starts listening:
const startServer = async () => {
  await connectDB();
  await connectRedis();

  // Pre-populate cache before accepting requests
  await warmCache();

  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
};

Why call warmCache after both connections?

The function needs both MongoDB (to query products) and Redis (to write the cache). Calling it after both connectDB() and connectRedis() guarantees both dependencies are available. If Redis failed to connect, the isReady check inside warmCache gracefully skips the operation.

  • Save server.js.
Verify cache warmup eliminates the cold start
  • Restart your server by running:
node server.js

Watch the console output. You should see the connection messages followed by Cache warmup complete: X products cached (where X is the number of products in your database).

  • Immediately hit GET /products (this is the very first request since the restart).

Before you check: do you expect this first-ever request to be a MISS or a HIT?

The response shows X-Cache: HIT on the very first request. No cold-start penalty. The warmup function populated the cache before any user traffic arrived.

First request still shows MISS?

  • Check your console for "Cache warmup skipped" messages. If you see "Redis not connected", the connectRedis() call before warmCache() might have failed silently.
  • Make sure warmCache() uses the same CACHE_VERSION prefix in its cache key as your getProducts function. A mismatch means the GET looks for a different key than what warmup wrote.
  • Confirm you added await before warmCache() in server.js. Without await, the server starts accepting requests before warmup finishes.

Why is my first request still a cache MISS even with warmCache?

Your API now starts hot. Every deployment, restart, or scale-up event delivers cached responses from the very first request. That's the final piece of production-grade caching.

Secret mission

Per-Product Caching with TTL

Your list cache is working great, but GET /products/:id still hits MongoDB on every single request. Can you add per-product caching where each product has its own Redis entry that expires automatically after 60 seconds?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Redis Cloud free tier (30 MB, no credit card required), so there are no ongoing costs.

Resources you used:

  • Redis Cloud free database (30 MB).
  • redis npm package added to your project.
  • config/redis.js and middleware/responseTime.js files created in your project.
  • Caching logic added to controllers/productController.js and REDIS_URL added to .env.

Keep everything running

No action needed. Choose this if you plan to keep building on your product API.

  • The Redis Cloud free tier has no time limit and no charges. Your cache stays active indefinitely.
  • Your local files and code changes remain in place, ready for the next time you start your server.

Pause - I'll come back to this later

No action needed for Redis Cloud. The free tier is always-on with no charges, so there is nothing to pause.

  • Stop your Express server if it is still running by pressing Ctrl+C in the terminal.
  • Your Redis Cloud database and all local code remain intact. Start your server again any time to resume.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete your Redis Cloud database: sign in to the Redis Cloud console, select your database from the list to open the Database screen, select More actions > Delete database, then click Delete database to confirm.
  • Remove the redis npm package by running:
npm uninstall redis
  • Delete the files created in this project by running:
rm config/redis.js middleware/responseTime.js
  • Open controllers/productController.js and remove all Redis-related code (the redisClient import, the PRODUCT_TTL constant, all try-catch blocks that call redisClient, and the getProductById function). Revert your controllers to query MongoDB directly.
  • Open .env and delete the REDIS_URL line.
  • Remove the Redis client import and connectRedis() call from your main server file.
  • Remove the responseTime middleware import and app.use(responseTime) call from your main server file.

Nice Work!

Nice Work!

Nice work! You just built a production-grade caching layer for your product API using Redis Cloud. Your API now serves cached reads 10-20x faster, retries connections intelligently, protects against stampedes, and starts hot on every deployment.

You've learned how to:

  • Implemented the Cache-Aside pattern with Redis Cloud and the official node-redis client, cutting GET /products response times from ~100ms to ~5-10ms on cached reads.
  • Added X-Cache and X-Response-Time headers for cache observability, plus cache invalidation on POST, PUT, and DELETE to eliminate stale data.
  • Built graceful degradation so your API falls back to MongoDB without crashing when Redis is unavailable.
  • Secret Mission: Added per-product caching with TTL expiry for automatic cache lifecycle management on individual product lookups.

Ready to quiz yourself?