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
Recommended: Using ctx (New)
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
varyoption works at the key-hash level — two requests with differentAuthorizationvalues produce different SHA-256 cache keys. No security risk of cross-contamination.
Multi-Tenant Caching
Recommended: Using ctx (New)
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
ctxsegment is embedded in the cache key before hashing, ensuring automatic invalidation via epochs works correctly. Unlike customkeyoverrides,ctxmaintains 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,autoInvalidatehas no effect. Usecache.invalidate()orcache.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
POSTrequests, you must provide a customkeygenerator (usinggenerateCacheKeyor custom serialization). If you do not specify a key, allPOSTrequests 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);Caching POST Requests | @express-route-cache
How to safely cache POST and non-GET requests using explicit enabling, custom keys, and the type-safe generateCacheKey helper.
Testing | @express-route-cache
How to write unit and integration tests for routes that use @express-route-cache, including adapter mocking and cache bypass strategies.