Cache Product Pages in Redis
Product pages are our most requested route, and every view runs a database read plus a pricing call. This caches the rendered page in Redis for 5 minutes and invalidates it when a product is updated. On staging the p95 for product pages dropped from 180 ms to 12 ms.
Asrc/catalog/productCache.ts1 of 3
Files Changed
src/catalog/
src/catalog/productCache.ts
TypeScript · Click a line number to comment · Tap a line to comment@@ -0,0 +1,29 @@
Added: import type { Redis } from "ioredis";
Added: import type { ProductPage } from "./types";
Added:
Added: const TTL_SECONDS = 300;
Added:
Added: /** Cache-aside store for rendered product pages. */
Added: export class ProductCache {
Added: constructor(private readonly redis: Redis) {}
Added:
Added: private key(productId: string): string {
Added: return `product-page:${productId}`;
Added: }
Added:
Added: async getOrLoad(
Added: productId: string,
Added: load: () => Promise<ProductPage | null>,
Added: ): Promise<ProductPage | null> {
Added: const cached = await this.redis.get(this.key(productId));
Added: if (cached !== null) return JSON.parse(cached) as ProductPage | null;
Added:
Added: const page = await load();
Added: await this.redis.set(this.key(productId), JSON.stringify(page), "EX", TTL_SECONDS);
Added: return page;
Added: }
Added:
Added: async invalidate(productId: string): Promise<void> {
Added: await this.redis.del(this.key(productId));
Added: }
Added: }
src/catalog/productService.ts
TypeScript · Click a line number to comment · Tap a line to comment@@ -1,25 +1,39 @@
Added: import type { Redis } from "ioredis";
import type { Db } from "../db";
Added: import { logger } from "../logger";
import type { PricingClient } from "../pricing/client";
Added: import { ProductCache } from "./productCache";
import { renderPage } from "./render";
import type { ProductPage, ProductUpdate, RequestContext } from "./types";
export class ProductService {
Added: private readonly cache: ProductCache;
Added:
constructor(
private readonly db: Db,
private readonly pricing: PricingClient,
Removed: ) {}
Added: redis: Redis,
Added: ) {
Added: this.cache = new ProductCache(redis);
Added: }
async getProductPage(productId: string, ctx: RequestContext): Promise<ProductPage | null> {
Removed: const product = await this.db.products.findById(productId);
Removed: if (!product) return null;
Removed: const price = await this.pricing.priceFor(product.sku, ctx.currency);
Removed: return renderPage(product, price, ctx.locale);
Added: return this.cache.getOrLoad(productId, async () => {
Added: const product = await this.db.products.findById(productId);
Added: if (!product) return null;
Added: const price = await this.pricing.priceFor(product.sku, ctx.currency).catch((err) => {
Added: logger.warn({ err, productId }, "price lookup failed, rendering without a price");
Added: return null;
Added: });
Added: return renderPage(product, price, ctx.locale);
Added: });
}
async updateProduct(productId: string, update: ProductUpdate): Promise<void> {
Added: await this.cache.invalidate(productId);
await this.db.transaction(async (tx) => {
await tx.products.update(productId, update);
await tx.priceHistory.record(productId, update.prices);
});
}
}
src/catalog/productCache.test.ts
TypeScript · Click a line number to comment · Tap a line to comment@@ -0,0 +1,28 @@
Added: import RedisMock from "ioredis-mock";
Added: import { beforeEach, describe, expect, it, vi } from "vitest";
Added: import { aProductPage } from "./fixtures";
Added: import { ProductCache } from "./productCache";
Added:
Added: describe("ProductCache", () => {
Added: let cache: ProductCache;
Added:
Added: beforeEach(() => {
Added: cache = new ProductCache(new RedisMock());
Added: });
Added:
Added: it("loads once and then serves the cached page", async () => {
Added: const load = vi.fn().mockResolvedValue(aProductPage({ id: "p1" }));
Added: await cache.getOrLoad("p1", load);
Added: const page = await cache.getOrLoad("p1", load);
Added: expect(page?.id).toBe("p1");
Added: expect(load).toHaveBeenCalledTimes(1);
Added: });
Added:
Added: it("reloads after invalidate", async () => {
Added: const load = vi.fn().mockResolvedValue(aProductPage({ id: "p1" }));
Added: await cache.getOrLoad("p1", load);
Added: await cache.invalidate("p1");
Added: await cache.getOrLoad("p1", load);
Added: expect(load).toHaveBeenCalledTimes(2);
Added: });
Added: });