Application Caching Patterns
Fragment caching, query result caching, session stores, computed value memoization — practical patterns for real applications.
Why Application-Level Caching
HTTP caching protects your server from repeat requests for the same URL. Database caching stores query results. But there’s a middle layer — within your application — where expensive computation happens that doesn’t map cleanly to either.
The problem: function calls are expensive. Whether it’s a database query that joins five tables, a third-party API call, a rendered HTML fragment, or a computed permission check — calling it 1000 times when the result doesn’t change is waste.
Real-World Analogy
A tax accountant who recalculates your entire tax return every time you ask a question is slow and expensive. One who writes the intermediate results on a scratch pad — and only recalculates when the underlying numbers change — is fast. Application caching is the scratch pad.
Query Result Caching
Cache the result of expensive database queries:
class QueryCache {
constructor(private redis: RedisClient) {}
async query<T>(
key: string,
queryFn: () => Promise<T>,
ttl = 60,
): Promise<T> {
const cached = await this.redis.get(key);
if (cached) return JSON.parse(cached);
const result = await queryFn();
await this.redis.setEx(key, ttl, JSON.stringify(result));
return result;
}
}
const qc = new QueryCache(redis);
// Cache expensive aggregate query for 5 minutes
const stats = await qc.query(
'stats:dashboard:2026-05',
() => db.query(`
SELECT
COUNT(*) AS total_orders,
SUM(amount) AS revenue,
AVG(amount) AS avg_order
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
`),
300,
); Key composition for parameterized queries:
import { stringify } from 'fast-json-stable-stringify';
function queryKey(name: string, params: object): string {
return `query:${name}:${stringify(params)}`;
}
const users = await qc.query(
queryKey('users.search', { role: 'admin', page: 1, limit: 20 }),
() => db.users.search({ role: 'admin', page: 1, limit: 20 }),
60,
); Session Store
Sessions are a classic cache use case: small, frequently read, tied to a TTL.
import { randomBytes } from 'crypto';
interface Session {
userId: string;
roles: string[];
createdAt: number;
}
class SessionStore {
private TTL = 86400; // 24 hours
constructor(private redis: RedisClient) {}
async create(userId: string, roles: string[]): Promise<string> {
const sessionId = randomBytes(32).toString('hex');
const session: Session = { userId, roles, createdAt: Date.now() };
await this.redis.setEx(`session:${sessionId}`, this.TTL, JSON.stringify(session));
return sessionId;
}
async get(sessionId: string): Promise<Session | null> {
const raw = await this.redis.get(`session:${sessionId}`);
return raw ? JSON.parse(raw) : null;
}
async touch(sessionId: string): Promise<void> {
// Reset TTL on each request (sliding expiry)
await this.redis.expire(`session:${sessionId}`, this.TTL);
}
async destroy(sessionId: string): Promise<void> {
await this.redis.del(`session:${sessionId}`);
}
}
// Middleware
async function sessionMiddleware(req, res, next): Promise<void> {
const sessionId = req.cookies?.sessionId;
if (!sessionId) return next();
const session = await sessions.get(sessionId);
if (!session) return next();
await sessions.touch(sessionId); // sliding expiry
req.session = session;
next();
} Memoization
Cache the result of a pure function keyed by its arguments. Works for in-process or distributed:
// In-process memoization (survives only in this process)
function memoize<TArgs extends unknown[], TReturn>(
fn: (...args: TArgs) => TReturn,
keyFn: (...args: TArgs) => string = (...args) => JSON.stringify(args),
): (...args: TArgs) => TReturn {
const cache = new Map<string, TReturn>();
return (...args: TArgs): TReturn => {
const key = keyFn(...args);
if (cache.has(key)) return cache.get(key)!;
const result = fn(...args);
cache.set(key, result);
return result;
};
}
// Memoize a permission check
const canAccess = memoize(
(userId: string, resource: string): boolean => {
return computePermissions(userId, resource);
},
(userId, resource) => `${userId}:${resource}`,
); Async memoization with Redis:
function memoizeAsync<TArgs extends unknown[], TReturn>(
fn: (...args: TArgs) => Promise<TReturn>,
options: { ttl: number; keyFn?: (...args: TArgs) => string },
) {
const { ttl, keyFn = (...args) => JSON.stringify(args) } = options;
return async (...args: TArgs): Promise<TReturn> => {
const key = `memo:${fn.name}:${keyFn(...args)}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const result = await fn(...args);
await redis.setEx(key, ttl, JSON.stringify(result));
return result;
};
}
const getPermissions = memoizeAsync(
async (userId: string) => db.permissions.forUser(userId),
{ ttl: 300, keyFn: (userId) => userId },
); Fragment Caching
Cache partial outputs — rendered HTML snippets, partial API payloads — rather than full responses.
// Cache just the expensive part of a response
async function getProductPage(productId: string): Promise<ProductPage> {
const [product, cachedRelated, cachedReviews] = await Promise.all([
getProduct(productId), // always fresh
cache.get(`related:${productId}`) as Promise<Product[] | null>, // cached
cache.get(`reviews:${productId}`) as Promise<Review[] | null>, // cached
]);
const related = cachedRelated ?? await fetchAndCache(
`related:${productId}`,
() => getRelatedProducts(productId),
600, // 10 min — related products change slowly
);
const reviews = cachedReviews ?? await fetchAndCache(
`reviews:${productId}`,
() => getRecentReviews(productId),
60, // 1 min — reviews can change frequently
);
return { product, related, reviews };
} This is more granular than full-response caching — different fragments have different TTLs matching their actual rate of change.
Computed Values and Aggregations
Pre-compute and cache values that are expensive to derive on demand:
class Leaderboard {
private CACHE_KEY = 'leaderboard:top100';
private TTL = 60; // rebuild every minute
async getTop100(): Promise<LeaderboardEntry[]> {
const cached = await redis.get(this.CACHE_KEY);
if (cached) return JSON.parse(cached);
// Expensive: scans millions of user records
const entries = await db.query(`
SELECT user_id, SUM(points) AS total
FROM point_events
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY user_id
ORDER BY total DESC
LIMIT 100
`);
await redis.setEx(this.CACHE_KEY, this.TTL, JSON.stringify(entries));
return entries;
}
async addPoints(userId: string, points: number): Promise<void> {
await db.pointEvents.insert({ userId, points });
// Don't invalidate — let TTL handle it
// Leaderboard can be 1 minute stale — that's fine
}
} When real-time accuracy matters more, maintain the leaderboard incrementally:
// Use a sorted set — O(log N) updates, O(1) rank queries
async function addPoints(userId: string, points: number): Promise<void> {
await redis.zIncrBy('leaderboard:live', points, userId);
}
async function getTop100(): Promise<Array<{ userId: string; score: number }>> {
return redis.zRangeWithScores('leaderboard:live', 0, 99, { REV: true });
} Deduplication Cache
Prevent processing the same event twice (idempotency):
class IdempotencyCache {
async processOnce<T>(
idempotencyKey: string,
handler: () => Promise<T>,
ttl = 86400, // 24h
): Promise<T> {
const existingResult = await redis.get(`idempotent:${idempotencyKey}`);
if (existingResult) {
return JSON.parse(existingResult); // return cached result
}
const result = await handler();
await redis.setEx(`idempotent:${idempotencyKey}`, ttl, JSON.stringify(result));
return result;
}
}
// Webhook handler — safe to retry
app.post('/webhooks/payment', async (req, res) => {
const { idempotencyKey, payload } = req.body;
const result = await idempotencyCache.processOnce(
idempotencyKey,
() => processPayment(payload),
);
res.json(result);
}); Negative Caching
Cache the fact that something doesn’t exist, preventing repeated DB lookups for non-existent keys:
const CACHE_NULL = '__NULL__';
async function getUser(id: string): Promise<User | null> {
const cached = await redis.get(`user:${id}`);
if (cached === CACHE_NULL) return null; // cached non-existence
if (cached) return JSON.parse(cached);
const user = await db.users.findById(id);
if (!user) {
// Cache the miss for 60s — prevents DB hammering for bogus IDs
await redis.setEx(`user:${id}`, 60, CACHE_NULL);
return null;
}
await redis.setEx(`user:${id}`, 300, JSON.stringify(user));
return user;
} Use short TTLs for negative caches. If a user signs up, you don’t want other services to keep getting a negative cache response for minutes. 30–60 seconds is usually enough to protect the database without causing visible inconsistency.