- Published on
O(1) Cache Invalidation: Timestamp Flags Instead of Key Deletion
- Authors
The Problem: Cache Invalidation That Takes Forever
We had a dual-Redis caching system with PRIMARY (3 days) and FALLBACK (7 days) storage for Stale-While-Revalidate (SWR). When content changed in the dashboard, we needed to invalidate the cache. Simple, right?
Here's what we did:
// ❌ WRONG: The slow way
export const cleanCachePartially = async () => {
await Promise.all([
useStorage('cache').clear(), // Iterates ALL keys
useStorage('cache-fallback').clear(), // Iterates ALL keys again
]);
};
The problem? With hundreds of thousands of cached entries, clearing took 5-10 seconds. Users clicked "Clear Cache" in the dashboard and... nothing. They'd click again. And again. Meanwhile, the cache was still clearing from the first click.
Worse, clearing both caches defeated the entire purpose of SWR. After invalidation:
- PRIMARY cache: empty ❌
- FALLBACK cache: empty ❌
- Next request: BLOCKING fetch instead of serving stale data 🐢
The Solution: Timestamp Flags
Instead of deleting millions of keys, we store two global timestamp flags:
cache:flag:full- Complete invalidation timestampcache:flag:partial- Partial invalidation timestamp (triggers SWR)
Each cache entry stores its creation timestamp. When retrieving, we compare:
- Entry created AFTER flags? → Valid cache ✅
- Entry created BEFORE flags? → Invalid ❌
Invalidation becomes a single Redis write (< 5ms) instead of iterating millions of keys.
Implementation: The Cache Handler
Core Type Definition
const TTL = 259200; // 3 days
const FULL_RESET_FLAG_KEY = 'cache:flag:full';
const PARTIAL_RESET_FLAG_KEY = 'cache:flag:partial';
type CacheEntry<T> = {
data: T;
createdAt: number; // Unix timestamp from getCurrentUTC().getTime()
};
The Event Handler
export const defineCustomCacheEventHandler = <T>(
handler: (event: H3Event) => T | Promise<T>,
) => {
return originalDefineEventHandler(async (event: H3Event) => {
if (process.env.NODE_ENV === 'development') {
return await handler(event);
}
const url = getRequestURL(event);
const cacheKey = encodeURIComponent(url.hostname + url.pathname + url.search);
const storage = useStorage('cache');
// Parallel fetch: entry + flags
const [entry, fullResetRaw, partialResetRaw] = await Promise.all([
storage.getItem<CacheEntry<T>>(cacheKey),
storage.getItem<number>(FULL_RESET_FLAG_KEY),
storage.getItem<number>(PARTIAL_RESET_FLAG_KEY),
]);
const fullReset = fullResetRaw || 0;
const partialReset = partialResetRaw || 0;
const entryCreatedAt = entry?.createdAt || 0;
// Valid cache: entry created AFTER both flags
if (entry && entryCreatedAt > fullReset && entryCreatedAt > partialReset) {
console.log(`[Cache] HIT - ${cacheKey}`);
return entry.data;
}
// Auth users always get fresh data
const session = await getServerSession(event);
if (session?.user) {
console.log(`[Cache] Auth user - fetching fresh`);
const result = await handler(event);
await storage.setItem(cacheKey, {
data: result,
createdAt: getCurrentUTC().getTime()
}, { ttl: TTL });
return result;
}
// SWR mode: entry created AFTER fullReset but NOT after partialReset
if (entry && entryCreatedAt > fullReset) {
console.log(`[Cache] HIT (SWR) - ${cacheKey}`);
// Background revalidation
const waitUntil = event.context.waitUntil || event.context.cloudflare?.ctx?.waitUntil;
const revalidate = async () => {
try {
const result = await handler(event);
await storage.setItem(cacheKey, {
data: result,
createdAt: getCurrentUTC().getTime()
}, { ttl: TTL });
} catch (error) {
console.error(`[Cache] Revalidation failed - ${cacheKey}:`, error);
}
};
if (waitUntil) {
waitUntil(revalidate());
} else {
revalidate().catch(() => {});
}
return entry.data; // Serve stale immediately
}
// COLD miss - fetch fresh
console.log(`[Cache] COLD miss - ${cacheKey}`);
const result = await handler(event);
await storage.setItem(cacheKey, {
data: result,
createdAt: getCurrentUTC().getTime()
}, { ttl: TTL });
return result;
});
};
Invalidation Functions
// Partial invalidation - preserves SWR
export const cleanCachePartially = async () => {
const storage = useStorage('cache');
await storage.setItem(PARTIAL_RESET_FLAG_KEY, getCurrentUTC().getTime());
console.log(`[Cache] Partial reset flag set - stale entries serve via SWR`);
};
// Complete invalidation - for critical data issues
export const cleanCacheCompletely = async () => {
const storage = useStorage('cache');
await storage.setItem(FULL_RESET_FLAG_KEY, getCurrentUTC().getTime());
console.log(`[Cache] Full reset flag set - all entries invalidated`);
};
How It Works: Flow Scenarios
Scenario 1: Fresh Cache (No Invalidation)
- Entry created at timestamp
1000 - No flags set (both
0) - Check:
1000 > 0 && 1000 > 0→ TRUE ✅ - Result: Returns cached data instantly
Scenario 2: Partial Invalidation (SWR Mode)
- Entry created at
1000 - Partial reset flag set to
2000 - Full reset not set (
0) - Check:
1000 > 0 && 1000 > 2000→ FALSE - Falls to SWR check:
1000 > 0→ TRUE - Result: Serves stale data + background revalidation
Scenario 3: Full Invalidation
- Entry created at
1000 - Full reset flag set to
3000 - Check:
1000 > 3000→ FALSE - Falls to SWR check:
1000 > 3000→ FALSE - Result: Cold miss, fetches fresh data
Scenario 4: After Revalidation
- Partial reset at
2000 - New entry created at
3000(after background revalidation) - Full reset not set (
0) - Check:
3000 > 0 && 3000 > 2000→ TRUE ✅ - Result: Fresh cache hit
Critical Implementation Details
Why entry?.createdAt || 0 is Safe
const entryCreatedAt = entry?.createdAt || 0;
if (entry && entryCreatedAt > fullReset && entryCreatedAt > partialReset) {
return entry.data;
}
The entry && check short-circuits before timestamp comparison. If entry doesn't exist, we never evaluate entryCreatedAt > flag, so defaulting to 0 is safe.
Legacy entries without createdAt? They default to 0, which is older than any real timestamp, so they get invalidated on first flag write. Perfect.
UTC Timestamps for Consistency
ALWAYS use getCurrentUTC() for timestamps:
import { getCurrentUTC } from '~/utils/timezone.utils';
// ✅ CORRECT: Server timezone independent
createdAt: getCurrentUTC().getTime()
// ❌ WRONG: Depends on server timezone
createdAt: new Date().getTime()
This ensures cache behavior is identical regardless of server location.
Auth Bypass for Dynamic Content
Authenticated users always get fresh data (no cache):
const session = await getServerSession(event);
if (session?.user) {
const result = await handler(event);
await storage.setItem(cacheKey, {
data: result,
createdAt: getCurrentUTC().getTime()
}, { ttl: TTL });
return result;
}
This check happens AFTER the primary cache hit check, so 95% of requests (cache hits) never call getServerSession() at all.
Performance Impact
| Metric | Before (Key Deletion) | After (Timestamp Flags) |
|---|---|---|
| Invalidation Time | 5-10 seconds | < 5ms |
| Dashboard Responsiveness | Unresponsive during clear | Instant feedback |
| Post-Invalidation Requests | BLOCKING fetch | SWR (serve stale + revalidate) |
| Redis Operations | O(n) key iteration | O(1) flag write |
| Storage Cleanup | Manual deletion | Natural TTL expiry |
Common Pitfalls to Avoid
Don't: Use < Comparison
// ❌ WRONG: Confusing semantics
if (entry && entry.createdAt < partialReset) {
return entry.data; // Returns INVALID cache!
}
The > comparison is semantically correct:
- Entry created AFTER invalidation flag = valid (newer than invalidation event)
- Entry created BEFORE invalidation flag = invalid (older than invalidation event)
Don't: Clear Both Caches for Partial Invalidation
// ❌ WRONG: Defeats SWR purpose
export const cleanCachePartially = async () => {
await Promise.all([
useStorage('cache').clear(),
useStorage('cache-fallback').clear(), // Don't do this!
]);
};
Partial invalidation should preserve FALLBACK for SWR mode.
Don't: Forget Background Revalidation Error Handling
// ⚠️ CAREFUL: Silent failures
revalidate().catch(() => {}); // Swallows errors
// ✅ BETTER: Log for monitoring
revalidate().catch((error) => {
console.error('[Cache] Background revalidation failed:', error);
});
Multi-App Consistency
We implemented this pattern across two apps with slight variations:
deante.pl (with auth):
// Has getServerSession check
if (session?.user) {
const result = await handler(event);
// ...
}
deantedesign.studio (public site):
// Auth section commented out for future implementation
// TODO: Uncomment when auth is implemented
// if (session?.user) { ... }
Both use identical:
- Flag names (
FULL_RESET_FLAG_KEY,PARTIAL_RESET_FLAG_KEY) - TTL (259200 seconds / 3 days)
- Comparison logic (
entryCreatedAt > flag) - Log message format
Best Practices
- Always use
getCurrentUTC()for timestamps (server timezone independence) - Parallel fetch flags and entry with
Promise.all(performance) - Default missing flags to
0(backward compatible) - Check cache hit BEFORE auth (avoid unnecessary session lookups)
- Use SWR for partial invalidation (preserve performance during content updates)
- Complete invalidation only for critical issues (wrong data, security bugs, schema changes)
When to Use Each Invalidation Type
Partial Reset (cleanCachePartially)
Use when:
- Content updated in CMS
- Product prices/stock changed
- Non-critical data refresh needed
Behavior: Triggers SWR mode - serves stale data immediately, revalidates in background
Full Reset (cleanCacheCompletely)
Use when:
- Critically wrong data must not be served
- Database schema changes (old cache incompatible)
- Security issues (sensitive data leaked)
Behavior: Forces cold miss - all requests fetch fresh data
Summary
Key takeaways from our timestamp-based cache invalidation:
- O(1) invalidation via single flag write instead of O(n) key iteration
- SWR preserved during partial invalidation for zero-latency content updates
- Natural expiry via TTL eliminates need for manual cleanup
- UTC timestamps ensure consistent behavior across regions
- Auth-aware caching optimizes for both public and authenticated traffic
The pattern is production-tested on an e-commerce platform serving 11+ European markets. Cache invalidation went from 5-10 seconds to < 5ms, dashboard became instantly responsive, and SWR mode eliminated post-invalidation latency spikes.
Real pattern from a Nuxt 3 e-commerce monorepo with dual-Redis caching. 100% reduction in cache invalidation time (5-10s → <5ms).