[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an API Response Cache Layer

Build an API Response Cache Layer

Build an API response cache layer with TTL management, cache invalidation strategies, conditional requests, stale-while-revalidate, and per-endpoint configuration for API performance.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 4 skills

Avg quality 95/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Liam leads backend at a 20-person company. Their API serves 10K requests/second but most responses are identical — product listings, category pages, and config endpoints return the same data for thousands of users. Database handles 8K queries/second for data that changes once per hour. Adding Redis caching per-endpoint is scattered across 50 handlers with inconsistent TTLs and no invalidation strategy. They need a centralized cache layer: configurable per-endpoint, automatic invalidation on writes, conditional requests (ETag/If-None-Match), stale-while-revalidate, and cache analytics.

Step 1: Build the Cache Layer

typescript
// src/cache/layer.ts — API response cache with invalidation and conditional requests
import { Redis } from "ioredis";
import { createHash } from "node:crypto";

const redis = new Redis(process.env.REDIS_URL!);

interface CacheConfig {
  path: string;
  ttl: number;
  staleWhileRevalidate: number;
  varyBy: string[];
  invalidateOn: string[];
  methods: string[];
}

const CACHE_CONFIGS: CacheConfig[] = [
  { path: "/api/products", ttl: 60, staleWhileRevalidate: 30, varyBy: ["category", "page"], invalidateOn: ["product.created", "product.updated", "product.deleted"], methods: ["GET"] },
  { path: "/api/categories", ttl: 300, staleWhileRevalidate: 60, varyBy: [], invalidateOn: ["category.updated"], methods: ["GET"] },
  { path: "/api/config", ttl: 600, staleWhileRevalidate: 120, varyBy: ["tenant"], invalidateOn: ["config.updated"], methods: ["GET"] },
  { path: "/api/user/profile", ttl: 0, staleWhileRevalidate: 0, varyBy: [], invalidateOn: [], methods: [] },
];

interface CachedResponse {
  body: string;
  status: number;
  headers: Record<string, string>;
  etag: string;
  cachedAt: number;
  ttl: number;
}

// Middleware: check cache before handler, cache after
export function cacheMiddleware() {
  return async (c: any, next: any) => {
    const config = findConfig(c.req.path, c.req.method);
    if (!config || config.ttl === 0) return next();

    const cacheKey = buildCacheKey(c.req, config);

    // Check conditional request (ETag)
    const ifNoneMatch = c.req.header("If-None-Match");
    if (ifNoneMatch) {
      const cachedEtag = await redis.get(`${cacheKey}:etag`);
      if (cachedEtag && cachedEtag === ifNoneMatch) {
        await redis.hincrby("cache:stats", "conditionalHits", 1);
        return c.body(null, 304);
      }
    }

    // Check cache
    const cached = await redis.get(cacheKey);
    if (cached) {
      const entry: CachedResponse = JSON.parse(cached);
      const age = (Date.now() - entry.cachedAt) / 1000;

      if (age < config.ttl) {
        // Fresh — serve from cache
        await redis.hincrby("cache:stats", "hits", 1);
        c.header("X-Cache", "HIT");
        c.header("Cache-Control", `max-age=${Math.ceil(config.ttl - age)}`);
        c.header("ETag", entry.etag);
        c.header("Age", String(Math.ceil(age)));
        return c.json(JSON.parse(entry.body), entry.status);
      }

      if (age < config.ttl + config.staleWhileRevalidate) {
        // Stale but within SWR window — serve stale, revalidate in background
        await redis.hincrby("cache:stats", "staleHits", 1);
        c.header("X-Cache", "STALE");
        c.header("ETag", entry.etag);
        revalidateInBackground(c.req, config, cacheKey).catch(() => {});
        return c.json(JSON.parse(entry.body), entry.status);
      }
    }

    // Cache miss — run handler
    await redis.hincrby("cache:stats", "misses", 1);
    await next();

    // Cache the response
    if (c.res.status < 400) {
      const body = await c.res.clone().text();
      const etag = createHash("md5").update(body).digest("hex").slice(0, 16);

      const entry: CachedResponse = {
        body, status: c.res.status,
        headers: Object.fromEntries(c.res.headers.entries()),
        etag, cachedAt: Date.now(), ttl: config.ttl,
      };

      await redis.setex(cacheKey, config.ttl + config.staleWhileRevalidate, JSON.stringify(entry));
      await redis.setex(`${cacheKey}:etag`, config.ttl + config.staleWhileRevalidate, etag);

      c.header("X-Cache", "MISS");
      c.header("ETag", etag);
      c.header("Cache-Control", `max-age=${config.ttl}`);
    }
  };
}

// Invalidate cache when data changes
export async function invalidate(event: string): Promise<number> {
  let invalidated = 0;
  for (const config of CACHE_CONFIGS) {
    if (config.invalidateOn.includes(event)) {
      const pattern = `cache:${config.path}:*`;
      const keys = await redis.keys(pattern);
      if (keys.length > 0) {
        await redis.del(...keys);
        invalidated += keys.length;
      }
    }
  }
  await redis.hincrby("cache:stats", "invalidations", invalidated);
  return invalidated;
}

async function revalidateInBackground(req: any, config: CacheConfig, cacheKey: string): Promise<void> {
  // Mark as revalidating to prevent duplicate revalidation
  const revalKey = `${cacheKey}:revalidating`;
  const isRevalidating = await redis.set(revalKey, "1", "EX", 10, "NX");
  if (!isRevalidating) return;

  try {
    // Re-fetch from origin
    const response = await fetch(`${process.env.INTERNAL_URL}${req.path}`, {
      headers: { "X-Cache-Bypass": "1" },
    });
    if (response.ok) {
      const body = await response.text();
      const etag = createHash("md5").update(body).digest("hex").slice(0, 16);
      await redis.setex(cacheKey, config.ttl + config.staleWhileRevalidate, JSON.stringify({
        body, status: response.status, headers: {}, etag, cachedAt: Date.now(), ttl: config.ttl,
      }));
    }
  } finally {
    await redis.del(revalKey);
  }
}

// Cache analytics
export async function getCacheStats(): Promise<{
  hits: number; misses: number; staleHits: number; conditionalHits: number;
  invalidations: number; hitRate: number;
}> {
  const stats = await redis.hgetall("cache:stats");
  const hits = parseInt(stats.hits || "0") + parseInt(stats.staleHits || "0") + parseInt(stats.conditionalHits || "0");
  const misses = parseInt(stats.misses || "0");
  const total = hits + misses;

  return {
    hits: parseInt(stats.hits || "0"),
    misses,
    staleHits: parseInt(stats.staleHits || "0"),
    conditionalHits: parseInt(stats.conditionalHits || "0"),
    invalidations: parseInt(stats.invalidations || "0"),
    hitRate: total > 0 ? Math.round((hits / total) * 100) : 0,
  };
}

function findConfig(path: string, method: string): CacheConfig | null {
  return CACHE_CONFIGS.find((c) => path.startsWith(c.path) && (c.methods.length === 0 || c.methods.includes(method))) || null;
}

function buildCacheKey(req: any, config: CacheConfig): string {
  const vary = config.varyBy.map((v) => req.query(v) || req.header(`x-${v}`) || "").join(":");
  return `cache:${req.path}:${vary}`;
}

Results

  • DB queries: 8K/sec → 800/sec — 90% cache hit rate on product and category endpoints; database load reduced 10x; headroom for growth
  • Stale-while-revalidate — expired cache serves instant stale response while origin fetched in background; users never see slow responses during cache refresh
  • ETag conditional requests — clients send If-None-Match; unchanged responses return 304 with zero body; saves 60% bandwidth for repeat visitors
  • Event-based invalidation — product.updated event fires → all product cache keys invalidated in <10ms; no stale data after writes; no manual cache busting
  • Centralized config — all cache rules in one file; change TTL for /api/products from 60s to 120s → redeploy; no touching 50 handlers

Related use cases