A complete rule set for designing, implementing, and operating multi-tier caching in high-traffic TypeScript/Node.js applications.
You're serving thousands of requests per second, but your p95 latencies are creeping into double digits. Your database is drowning under read load, and your CDN bill is climbing because you're missing obvious optimization opportunities. You need caching that actually works at scale.
Most developers implement caching backwards. They start with Redis, throw in some TTLs, and call it done. Then reality hits:
The real problem? You're treating caching as a simple key-value store instead of a sophisticated, multi-tiered system that requires the same engineering rigor as your core application logic.
These Cursor Rules implement a production-ready, multi-tier caching system that handles the complexities of high-traffic applications:
Edge CDN → HTTP Reverse Proxy → Distributed Cache → In-Memory LRU → Database
(60s TTL) (5s microcache) (minutes) (seconds) (source)
What makes this different:
Instead of managing Redis, Memcached, CDN configs, and in-memory caches separately, you get a unified TypeScript interface:
// Before: Managing multiple cache clients
const redisResult = await redisClient.get(`user:${id}`);
const parsedResult = JSON.parse(redisResult);
// Handle Redis errors, parsing errors, null checks...
// After: Unified, typed interface
const user = await cache.get<User>(`user:${id}`);
// Type-safe, error-handled, fallback-enabled
Built-in protection against the most common high-traffic cache failure:
// Automatic per-key locking prevents multiple database hits
// when popular cache keys expire simultaneously
const popularData = await cache.get<PopularContent>('trending:posts');
// Only one request hits the database, others wait for the result
Get actionable metrics instead of basic hit/miss counts:
Before: 30 minutes of setup, scattered across multiple files
// Manually manage Redis client
const redis = new Redis(process.env.REDIS_URL);
// Handle all error cases manually
async function getUserProfile(id: string) {
try {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
} catch (error) {
// Hope this doesn't crash the request
console.error('Cache error:', error);
}
const user = await db.user.findById(id);
// Remember to cache the result...
try {
await redis.setex(`user:${id}`, 300, JSON.stringify(user));
} catch (error) {
// Silent failure
}
return user;
}
After: 5 minutes, type-safe, production-ready
async function getUserProfile(id: string): Promise<User> {
return cache.getOrSet<User>(
cacheKeys.user.profile(id),
() => db.user.findById(id),
{ ttl: 300 }
);
}
// Handles: cache misses, errors, circuit breaking, metrics, type safety
Before: Manual invalidation scattered throughout your codebase
// Hope you remember to invalidate everywhere
await db.user.update(id, data);
await redis.del(`user:${id}`);
await redis.del(`user:${id}:profile`);
await redis.del(`user:${id}:permissions`);
// Did we miss any keys?
After: Centralized, pattern-based invalidation
await db.user.update(id, data);
await cache.invalidateByPattern(`user:${id}:*`);
// Automatically handles all user-related keys across all cache tiers
npm install @nestjs/cache-manager ioredis lru-cache p-limit
npm install -D @types/node redis-mock
Create your cache foundation:
// src/cache/client.ts
export interface CacheClient<T> {
get(key: string): Promise<T | null>;
set(key: string, value: T, ttlSec: number): Promise<void>;
getOrSet<U>(key: string, factory: () => Promise<U>, options: { ttl: number }): Promise<U>;
invalidateByPattern(pattern: string): Promise<number>;
}
// src/cache/cache.module.ts
@Module({
providers: [
{
provide: 'CACHE_CLIENT',
useFactory: () => new MultiTierCacheClient({
redis: { host: process.env.REDIS_HOST },
inMemory: { maxSize: 1000 },
keyPrefix: process.env.CACHE_KEY_PREFIX
})
}
]
})
export class CacheModule {}
// Automatic fallback when cache systems fail
const circuitBreaker = new CircuitBreaker(cacheOperation, {
timeout: 100, // 100ms timeout
threshold: 3, // Trip after 3 failures
resetTimeout: 60000 // Reset after 60s
});
// Prometheus metrics automatically exported
cache_hits_total{tier="redis"}
cache_misses_total{tier="redis"}
cache_errors_total{tier="redis"}
cache_latency_ms_bucket{tier="redis"}
Your high-traffic application deserves caching that works as hard as you do. These rules eliminate the guesswork and give you a battle-tested caching architecture that scales with your growth.
Stop losing milliseconds to poorly implemented caching. Your users will notice the difference immediately.
You are an expert in TypeScript, Node.js, Redis, Memcached, DragonflyDB, Varnish, Cloudflare/Fastly CDNs, AWS API Gateway, Prometheus, Grafana, Docker & Kubernetes.
Key Principles
- Optimise for the 95th percentile latency: every cache layer must reduce tail-latency, not just average.
- Prefer the cache-aside pattern; use write-through only when strict consistency is required.
- Implement multi-tier caching (edge CDN ➜ HTTP reverse proxy ➜ distributed cache ➜ in-memory LRU) and document the responsibility of each tier.
- Cache only deterministic, idempotent responses; never cache requests with side-effects.
- Define explicit TTLs; avoid infinite caching.
- Instrument, monitor, and alert on hit rate, eviction rate, latency, saturation, and error rate.
- Automate cache schema changes and invalidation with CI/CD pipelines.
TypeScript
- Enable "strict", "noUncheckedIndexedAccess", "exactOptionalPropertyTypes" in tsconfig.json.
- Declare a CacheClient<T> interface exposing get/set/delete/invalidate; never use the raw Redis client directly in business logic.
- Use generics to enforce typed cache payloads:
```ts
const user = await cache.get<User>(`user:${id}`);
```
- Enforce structured keys: `<domain>:<entity>:<id>[:<field>]` (e.g., `app:user:42:profile`).
- Prefix environment-specific keys (`dev:`, `staging:`) via a configurable KEY_PREFIX constant.
- Wrap cache operations in utility functions that return `Result<T, CacheError>` to avoid unhandled promise rejections.
- Use ESLint rule "no-magic-numbers" but allow [60, 1000, 1024] for TTLs & size constants.
Error Handling & Validation
- Differentiate cache states:
• HIT → return value.
• MISS → retrieve from source, populate cache, return value.
• ERROR → log warning, fall back to source, return value (never crash request).
- Place error/miss handling at the top of functions; happy path (HIT) at the bottom.
- Retry transient Redis/Memcached errors with exponential back-off (max 3 attempts, jittered).
- On repeated failures (>3 per minute) trip a circuit breaker for 60 s to protect backend.
- Validate payload size before set: abort if >1 MiB.
Node.js Frameworks (NestJS & Express)
- Use @nestjs/cache-manager with a Redis store for distributed cache; wrap it with the typed CacheClient interface.
- For Express, mount `apicache` or `express-redis-cache` middleware on idempotent GET routes only.
- Always pass `Cache-Control` and `ETag` headers downstream so CDNs can leverage microcaching (1-5 s) while origin caches hold longer (seconds → minutes).
- Implement a centralized `CacheInvalidationService` that publishes invalidation events to a Redis Pub/Sub channel; subscribers bust local in-memory caches.
- Expose an authenticated `/admin/cache/flush` endpoint for manual purges; require JWT "cache:flush" scope.
Additional Sections
Testing
- Use `redis-mock` or `@keyv/test-suite` for unit tests; assert HIT/MISS paths with Jest spies.
- Load-test with K6 or Artillery: emulate 50 k RPS, verify 90 % hit rate and <5 ms p95 cache latency.
- Include chaos tests: kill Redis primary and assert application degrades gracefully.
Performance & Monitoring
- Export Prometheus metrics: `cache_hits_total`, `cache_misses_total`, `cache_errors_total`, `cache_latency_ms_bucket`.
- Alert when hit rate <80 % for 5 minutes or Redis CPU >75 % for 10 minutes.
- Use Grafana dashboards with per-tier panels (CDN, Varnish, Redis, in-mem).
Security
- Never include PII in cache keys or unencrypted payloads.
- Encrypt sensitive payloads at rest using Redis AES / TLS in transit.
- Sanitize user-provided key parts; reject keys containing whitespace, `..`, or control chars.
Infrastructure & Deployment
- Deploy Redis/Memcached with Kubernetes StatefulSets, anti-affinity, and persistent volumes.
- Use Redis Cluster sharding; assign hash-tags in keys to minimise rehashing when scaling.
- Enable Redis 6 ACLs; application uses a role with only `GET/SET/DEL/EXPIRE`.
- For CDNs (Cloudflare/Fastly), configure:
• Edge TTL = 60 s (static), 5 s (dynamic micro-cache)
• `stale-while-revalidate=30`, `stale-if-error=300`.
CI/CD
- Run `npm run lint && npm run test && npm run k6` in pipeline.
- Auto-bump CACHE_SCHEMA_VERSION env var on breaking cache changes; previous keys expire naturally.
Edge Cases & Pitfalls
- Do not cache 4xx/5xx responses unless explicitly whitelisted.
- Avoid "cache stampede": wrap miss handler in a `p-limit(1)` per key or use Redis `SETNX` lock.
- Guard against "hot key" overload: enable Redis `hotkeys_tracking` and shard or localise.
- Implement proactive warm-up after deploy: hit key endpoints to pre-populate caches.
Directory Structure Example
```
src/
cache/
cache.module.ts # NestJS module wiring Redis provider
client.ts # CacheClient<T> generic wrapper
in-memory.ts # LRU cache using lru-cache pkg
invalidation.service.ts
keys.ts # key builders & prefixes
controllers/
services/
```
Example Typed Cache Wrapper
```ts
export interface CacheClient<T> {
get: (key: string) => Promise<T | null>;
set: (key: string, value: T, ttlSec: number) => Promise<void>;
del: (key: string) => Promise<void>;
invalidateByPattern: (pattern: string) => Promise<number>; // returns deleted count
}
```
By following these rules, teams can implement robust, observable, and performant caching layers that withstand high-traffic loads while maintaining data consistency and developer productivity.