Skip to main content
🔥
Guides

Recipes | @express-route-cache

Real-world caching patterns: per-user caching, cache warming, webhook invalidation, conditional caching, multi-tenant setups, and more.

Recipes

Copy-paste patterns for common real-world scenarios.


Per-User Caching

The ctx option provides the cleanest way to scope cache entries to arbitrary user context (user ID, role, organization, permissions). Unlike vary which works with HTTP headers, ctx gives you full access to both req and res (including res.locals from auth middleware).

app.get(
  "/api/profile",
  cache.route({
    ctx: (req, res) => {
      const user = res.locals.user;
      return user.id; // Each user gets their own cache entry
    },
  }),
  async (req, res) => {
    const user = await getUserFromToken(req.headers.authorization);
    res.json(user);
  },
);

// Invalidation - works the same regardless of ctx
app.post("/api/profile", cache.invalidate("/api/profile"), updateProfile);

Alternative: Using vary (Traditional)

Cache different responses per user by including the Authorization header in the cache key. Users with different tokens get their own private cache entries.

const cache = createCache({
  adapter: createRedisAdapter({ url: process.env.REDIS_URL }),
  vary: ["authorization"], // Cache key includes the Authorization header value
  staleTime: 120,
});

app.get("/api/profile", cache.route(), async (req, res) => {
  const user = await getUserFromToken(req.headers.authorization);
  res.json(user);
});

// Invalidation - works the same regardless of vary
app.post("/api/profile", cache.invalidate("/api/profile"), updateProfile);

[!TIP] The vary option works at the key-hash level — two requests with different Authorization values produce different SHA-256 cache keys. No security risk of cross-contamination.


Multi-Tenant Caching

app.get(
  "/api/dashboard",
  cache.route({
    ctx: (req, res) => {
      const tenantId = req.headers["x-tenant-id"] || "default";
      return tenantId;
    },
  }),
  async (req, res) => {
    const tenantId = req.headers["x-tenant-id"];
    const data = await getDashboardData(tenantId);
    res.json(data);
  },
);

// Invalidation - works the same regardless of ctx
app.post("/api/dashboard", cache.invalidate("/api/dashboard"), updateDashboard);

Alternative: Using vary (Traditional)

Isolate cache entries per tenant using a custom tenant header:

const cache = createCache({
  adapter: createRedisAdapter({ url: process.env.REDIS_URL }),
  vary: ["x-tenant-id"],
  staleTime: 60,
});

app.get("/api/dashboard", cache.route(), async (req, res) => {
  const tenantId = req.headers["x-tenant-id"];
  const data = await getDashboardData(tenantId);
  res.json(data);
});

// Invalidation - works the same regardless of vary
app.post("/api/dashboard", cache.invalidate("/api/dashboard"), updateDashboard);

Role and Permission-Based Caching (ctx only)

For complex permission-based scoping that requires combining multiple user attributes, ctx is the ideal choice:

app.get(
  "/api/customers",
  cache.route({
    ctx: (req, res) => {
      const user = res.locals.user;
      const hospitals = [...user.hospitals].sort().join(",");
      return `${user.role}:${hospitals}`;
      // → "admin:1,3" or "manager:1,2" or "viewer:"
    },
  }),
  handler,
);

// Invalidation - invalidates all permission-scoped entries
app.post("/api/customers", cache.invalidate("/api/customers"), createCustomer);

Combining ctx with vary

Use ctx for user-specific data and vary for HTTP-semantic variance (language, content-type):

const cache = createCache({
  adapter: createRedisAdapter({ url: process.env.REDIS_URL }),
  vary: ["accept-language"], // Separate cache per language
  staleTime: 60,
});

app.get(
  "/api/content",
  cache.route({
    ctx: (req, res) => {
      const user = res.locals.user;
      return user.organizationId; // Separate cache per organization
    },
  }),
  handler,
);

// Invalidation - invalidates all language and organization combinations
app.post("/api/content", cache.invalidate("/api/content"), updateContent);

[!TIP] The ctx segment is embedded in the cache key before hashing, ensuring automatic invalidation via epochs works correctly. Unlike custom key overrides, ctx maintains the full versioning system.


Query Parameter Determinism

Prevent cache fragmentation when clients send the same parameters in different orders:

// ?sort=name&page=1 and ?page=1&sort=name both hit the same cache entry
app.get("/api/products", cache.route({ sortQuery: true }), handler);

Custom Key for User-Specific Routes

Use a dynamic key function when your cache key needs to incorporate data beyond headers or query params:

app.get(
  "/api/users/:id/dashboard",
  cache.route({
    key: (req) => `user-dashboard:${req.params.id}`,
    staleTime: 300,
    swr: true,
  }),
  handler,
);

[!CAUTION] When using a custom key, autoInvalidate has no effect. Use cache.invalidate() or cache.invalidateRoute() for invalidation instead.


Caching POST Requests (e.g. Search / GraphQL)

By default, non-GET requests bypass the caching layer. However, you can explicitly cache safe/idempotent POST requests (such as complex searches or GraphQL queries) by enabling it and defining a custom cache key incorporating the request body:

import { generateCacheKey } from "@express-route-cache/core";

app.post(
  "/api/search",
  cache.route({
    enabled: true, // Explicitly allow caching for this POST route
    // Generate a unique hashed key from the request body
    key: (req) => generateCacheKey("search", req.body, ""),
  }),
  async (req, res) => {
    const results = await performSearch(req.body);
    res.json(results);
  },
);

[!IMPORTANT] When caching POST requests, you must provide a custom key generator (using generateCacheKey or custom serialization). If you do not specify a key, all POST requests to the same URL will share the same cache key regardless of the body payload.


Conditional Caching (Skip for Certain Requests)

Pre-create the cache middleware, then decide per-request whether to apply it:

const cachedRoute = cache.route({ staleTime: 60 });

app.get(
  "/api/feed",
  (req, res, next) => {
    // Bypass cache for admin users who always need fresh data
    if (req.headers["x-admin-bypass"] === "true") {
      return next(); // Goes straight to the handler below
    }
    return cachedRoute(req, res, next);
  },
  async (req, res) => {
    const feed = await getFeed();
    res.json(feed);
  },
);

Cache Warming on Startup

Pre-populate the cache with frequently-accessed data before your server starts accepting traffic. This prevents cold-start latency spikes on the first requests.

import { cache } from "./cache";
import { getTopProducts, getConfig } from "./services";

async function warmCache() {
  console.log("Warming cache...");

  await Promise.all([
    cache.fetch("top-products", () => getTopProducts(), { staleTime: 300 }),
    cache.fetch("app-config", () => getConfig(), { staleTime: 3600 }),
  ]);

  console.log("Cache warm ✅");
}

async function start() {
  await warmCache();
  app.listen(3000, () => console.log("Server ready"));
}

start();

Webhook-Triggered Invalidation

Invalidate the cache from a webhook (e.g., a CMS publish event, a Stripe webhook) without needing an active HTTP request:

import { cache } from "./cache";

// Contentful / Sanity webhook: content published
app.post("/webhooks/content", express.json(), async (req, res) => {
  const { contentType, slug } = req.body;

  // Invalidate the affected routes
  await cache.invalidateRoute(
    `/api/content/${contentType}`,
    `/api/content/${contentType}/${slug}`,
  );

  res.json({ invalidated: true });
});

Service Layer Caching with SWR and Retry

Wrap your database calls with cache.fetch() in the service layer — keeping your routes clean and your data fresh:

// posts.service.ts
import { cache } from "./cache";
import { db } from "./db";

export async function getPost(id: string) {
  return cache.fetch(`post:${id}`, () => db.posts.findById(id), {
    staleTime: 120,
    gcTime: 3600,
    swr: true,
    retry: 2,
  });
}

export async function updatePost(id: string, data: Partial<Post>) {
  const result = await db.posts.update(id, data);

  // Invalidate the post and the list
  await cache.invalidateRoute(`/api/posts/${id}`, "/api/posts");

  return result;
}

Cascading Invalidation

invalidateRoute accepts multiple patterns at once. Invalidate parent and child routes in a single O(1) call per pattern:

// When a comment is posted, invalidate: the comment list AND the post (which shows comment count)
app.post(
  "/api/posts/:postId/comments",
  cache.invalidate(`/api/posts/:postId/comments`, `/api/posts/:postId`),
  createComment,
);

Protecting Slow Aggregation Endpoints

For endpoints that aggregate across many tables (expensive to recompute), use a long staleTime with SWR so users never wait:

app.get(
  "/api/analytics/summary",
  cache.route({
    staleTime: 300, // Fresh for 5 minutes
    gcTime: 86400, // Keep stale for 24 hours
    swr: true, // Serve stale instantly while recomputing in background
  }),
  async (_req, res) => {
    const summary = await db.query(`
      SELECT COUNT(*) as users, SUM(revenue) as total
      FROM ...
    `);
    res.json(summary);
  },
);

Memory Protection for Large File Endpoints

Skip caching for responses that exceed a size limit to protect your Node.js heap:

app.get(
  "/api/exports/:id",
  cache.route({
    maxBodySize: 512 * 1024, // Only cache responses up to 512KB
    staleTime: 3600,
  }),
  generateExport,
);

Responses larger than maxBodySize are served normally — the cache is silently skipped, no error is thrown.


Exposing Cache Metrics in Your Monitoring Stack

Push cache.metrics to your metrics system (Prometheus, Datadog, etc.) on an interval:

import { cache } from "./cache";

// Push to your metrics collector every 30 seconds
setInterval(() => {
  const m = cache.metrics;
  if (!m) return;

  metrics.gauge("cache.hits", m.hits);
  metrics.gauge("cache.misses", m.misses);
  metrics.gauge("cache.swr_hits", m.swrHits);
  metrics.gauge("cache.swr_failures", m.swrFailures);
  metrics.gauge("cache.stampede_coalesces", m.stampedeCoalesces);
}, 30_000);