Cloudflare KV as a Cache Layer
Per-user recommendation caching, TTL design, cache invalidation strategies, and when to reach for KV versus D1 versus R2 in a Cloudflare Workers app.
Cloudflare KV is a globally distributed key-value store with eventual consistency. It's not a database — it's a cache. Understanding that distinction upfront saves you from the common mistake of treating it like one.
When KV Is the Right Choice
KV excels when:
- Read latency matters globally (KV serves from the nearest edge PoP)
- Data changes infrequently (KV has eventual consistency; writes propagate in ~60s)
- You need fast, cheap reads with tolerable stale data
KV is wrong when:
- You need strong consistency (use D1)
- You're storing large binaries >25 MB (use R2)
- You need SQL queries or indexes (use D1)
The Pattern: Per-User Recommendation Cache
In NodWatch, generating recommendations requires scanning watch history, computing similarity scores, and ranking results. This is too expensive to do on every page load. Instead, compute once, cache in KV, and serve from the edge.
// workers/cache.ts
const RECOMMENDATIONS_TTL = 60 * 60; // 1 hour in seconds
export async function getCachedRecommendations(
userId: string,
env: Env
): Promise<ContentItem[] | null> {
const key = `recs:${userId}`;
const cached = await env.KV.get(key, { type: "json" });
return cached as ContentItem[] | null;
}
export async function setCachedRecommendations(
userId: string,
recommendations: ContentItem[],
env: Env
): Promise<void> {
const key = `recs:${userId}`;
await env.KV.put(key, JSON.stringify(recommendations), {
expirationTtl: RECOMMENDATIONS_TTL,
});
}In the route handler:
// app/api/recommendations/route.ts
export async function GET(request: NextRequest, env: Env) {
const userId = await getUserId(request);
// Try cache first
const cached = await getCachedRecommendations(userId, env);
if (cached) {
return Response.json(cached, {
headers: { "X-Cache": "HIT" }
});
}
// Compute fresh recommendations
const recs = await computeRecommendations(userId, env.DB);
// Cache for next request (fire and forget)
env.ctx.waitUntil(setCachedRecommendations(userId, recs, env));
return Response.json(recs, {
headers: { "X-Cache": "MISS" }
});
}TTL Design
Different content needs different TTLs:
const TTL = {
recommendations: 60 * 60, // 1 hour — slow to change
watchProgress: 60 * 5, // 5 min — user is actively watching
trendingContent: 60 * 15, // 15 min — changes several times per day
userPreferences: 60 * 60 * 24, // 24 hours — rarely changes
streamTokens: 60, // 1 min — security-sensitive
} as const;For stream tokens specifically, KV's TTL is a security control: expired tokens can't be replayed even if leaked.
Cache Invalidation
KV doesn't support pattern-based invalidation (no DELETE recs:*). You invalidate by key — which means you need to know the exact keys that are stale.
// Invalidate when user watches something new
export async function invalidateUserCache(userId: string, env: Env) {
await Promise.all([
env.KV.delete(`recs:${userId}`),
env.KV.delete(`prefs:${userId}`),
]);
// These keys will be regenerated lazily on next request
}For broader invalidation (e.g., new content added to the catalog), use a versioned key strategy:
// Store current cache version in D1
const version = await getContentVersion(env.DB);
const key = `recs:${userId}:v${version}`;When you publish new content, increment the version in D1. Old KV entries become unreachable and expire naturally. New requests compute with the new version key.
KV vs D1 vs R2 Decision Matrix
| Use Case | Tool | Reason | |---|---|---| | Per-user caches | KV | Edge reads, TTL-based expiry | | Session tokens | KV | Low-latency auth check | | Stream AES tokens | KV | TTL as security expiry | | Structured data, queries | D1 | SQL, consistency | | User watch history | D1 | Relational, needs joins | | Large media files | R2 | Binary blobs >25 MB | | HLS segments | R2 | Chunked video data | | App configuration | KV | Rarely updated, global read |
wrangler.toml Binding
[[kv_namespaces]]
binding = "KV"
id = "your-namespace-id"
preview_id = "your-preview-namespace-id"Access as env.KV in your Worker. For local development:
wrangler kv:namespace create "CACHE" --preview
wrangler dev # uses preview namespace automaticallyMonitoring Hit Rates
Track cache effectiveness by logging cache status:
const cacheStatus = cached ? "HIT" : "MISS";
console.log(JSON.stringify({
event: "cache_check",
userId,
key: "recommendations",
status: cacheStatus,
timestamp: Date.now()
}));In Cloudflare's dashboard, filter Workers logs by cache_check events. A hit rate below 70% for recommendations suggests your TTL is too short or your user base is too write-heavy to benefit from caching.