Kamil Owczarek
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 timestamp
  • cache: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 > 0TRUE
  • 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 > 2000FALSE
  • Falls to SWR check: 1000 > 0TRUE
  • Result: Serves stale data + background revalidation

Scenario 3: Full Invalidation

  • Entry created at 1000
  • Full reset flag set to 3000
  • Check: 1000 > 3000FALSE
  • Falls to SWR check: 1000 > 3000FALSE
  • 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 > 2000TRUE
  • 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

MetricBefore (Key Deletion)After (Timestamp Flags)
Invalidation Time5-10 seconds< 5ms
Dashboard ResponsivenessUnresponsive during clearInstant feedback
Post-Invalidation RequestsBLOCKING fetchSWR (serve stale + revalidate)
Redis OperationsO(n) key iterationO(1) flag write
Storage CleanupManual deletionNatural 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

  1. Always use getCurrentUTC() for timestamps (server timezone independence)
  2. Parallel fetch flags and entry with Promise.all (performance)
  3. Default missing flags to 0 (backward compatible)
  4. Check cache hit BEFORE auth (avoid unnecessary session lookups)
  5. Use SWR for partial invalidation (preserve performance during content updates)
  6. 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:

  1. O(1) invalidation via single flag write instead of O(n) key iteration
  2. SWR preserved during partial invalidation for zero-latency content updates
  3. Natural expiry via TTL eliminates need for manual cleanup
  4. UTC timestamps ensure consistent behavior across regions
  5. 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).