Skip to content

Stale-While-Revalidate (SWR) + Tag-Based Invalidation #2

@hoangsonww

Description

@hoangsonww

Summary
Add first-class support for Stale-While-Revalidate (serve cached data instantly while refreshing in the background) and tag-based invalidation (purge groups of related entries at once). This will improve perceived performance and give developers fine-grained control over cache consistency across fetch and Axios.

Motivation

  • Instant UX + Freshness: Many apps prefer returning slightly stale data immediately, then updating silently. SWR is a proven pattern for this.
  • Simple cache busting: Today you can clear the whole cache, or set/get individual keys. Real apps often need to invalidate sets of keys (e.g., “all pokemon/*” or “all user profile data”) when a mutation occurs.
  • Parity across adapters: Support should work for in-memory, localStorage, sessionStorage, IndexedDB, and Redis adapters via a minimal, portable API.

Goals

  1. Serve stale entries instantly if within a configurable stale window while kicking off a background refresh.
  2. Expose a tag API to associate one or more tags with a cache entry and invalidate by tag.
  3. Keep existing API backward-compatible; new behavior is opt-in.
  4. Work with both fetch interception and registered Axios instances.
  5. Provide deterministic behavior in Node and browser environments.

Non-Goals

  • Reactive UI bindings (React hooks, etc.)—app code can subscribe to events we emit.
  • Complex query invalidation logic (regex on keys is out of scope for v1; tag sets cover most needs).

Proposed API

// New options on enableGhostCache
type GhostCacheOptions = {
  ttl?: number;                 // existing
  persistent?: boolean;         // existing
  maxEntries?: number;          // existing
  storage?: StorageOption;      // existing

  swr?: {
    enabled?: boolean;          // default false
    staleTtl?: number;          // ms window during which stale can be served (<= ttl)
    revalidateOnFocus?: boolean; // browser only; triggers background refresh on window focus
    revalidateOnReconnect?: boolean; // triggers on online event
  };
};

// New manual cache APIs
export function setCache(
  key: string,
  value: any,
  opts?: { ttl?: number; tags?: string[] } // <-- NEW tags
): Promise<void>;

export function getCache<T = any>(
  key: string,
  opts?: { swr?: boolean } // when true, returns stale if available & schedules refresh
): Promise<T | null>;

export function invalidateTags(tags: string | string[]): Promise<void>; // <-- NEW

// Optional: events for UI layers/devtools
export type GhostCacheEvent =
  | { type: 'cache_hit'; key: string; stale: boolean }
  | { type: 'cache_miss'; key: string }
  | { type: 'revalidate_start'; key: string }
  | { type: 'revalidate_success'; key: string }
  | { type: 'revalidate_error'; key: string; error: unknown };

export function onGhostCacheEvent(listener: (e: GhostCacheEvent) => void): () => void;

Request-Level Overrides

// fetch: opt-in via Request init
fetch(url, {
  ghostCache: {
    swr: true,             // enable SWR for this request only
    tags: ['pokemon', 'list']
  }
});

// axios: opt-in via config
api.get('/pokemon/ditto', {
  ghostCache: { swr: true, tags: ['pokemon', 'detail:ditto'] }
});

If swr.enabled is false globally, per-request ghostCache.swr === true still enables SWR for that call.

Behavior Details

  • Cache states

    • Fresh (now < createdAt + ttl): return cached response; no refresh.
    • Stale-eligible (now < createdAt + staleTtl and swr enabled): return cached response immediately; trigger background revalidation; on success, cache new value and emit revalidate_success.
    • Expired: perform network request, cache, return result.
  • Deduping: if multiple identical requests arrive while a revalidation is in flight, they should share the same network promise (no thundering herd).

  • Tags: when setCache or an intercepted request completes, store any provided tags. invalidateTags(['pokemon']) removes all entries associated with those tags across adapters.

Storage Adapter Contract Changes

All existing adapters continue to work. To support tags efficiently, add two optional methods:

interface IStorageAdapter {
  // existing get/set/delete/clear...

  // Optional for performance; fallback uses full scan if not implemented.
  addTags?(key: string, tags: string[]): Promise<void>;
  deleteByTags?(tags: string[]): Promise<void>;
}
  • In-memory: maintain a Map<string, Set<string>> for tag index.
  • localStorage/sessionStorage: maintain a serialized tag index key (e.g., __ghostcache_tag_index__).
  • IndexedDB: add an index on tags.
  • Redis: store per-tag sets (SADD tag:<name> key), and on invalidate use SMEMBERS then DEL/UNLINK.

Adapters that don’t override deleteByTags fall back to scanning keys (OK for small caches).

Examples

enableGhostCache({
  ttl: 60_000,
  swr: { enabled: true, staleTtl: 30_000, revalidateOnFocus: true }
});

// Serve stale instantly, refresh in background
await fetch('https://pokeapi.co/api/v2/pokemon/ditto', {
  ghostCache: { swr: true, tags: ['pokemon', 'detail:ditto'] }
});

// After a mutation:
await invalidateTags(['pokemon']); // bust all pokemon-related entries

Acceptance Criteria

  • Opt-in SWR works for both fetch and registered Axios instances.

  • Background revalidation returns stale data immediately and updates cache on success.

  • Request deduplication prevents duplicate network calls during revalidation.

  • Tagging works via:

    • per-request ghostCache.tags
    • manual setCache(..., { tags })
    • invalidateTags([...]) purges matching entries across adapters.
  • Backward compatibility: existing APIs unaffected when swr.enabled is not used.

  • Comprehensive tests:

    • In-memory adapter (SWR + tags)
    • Browser-like storage (localStorage/sessionStorage) via JSDOM/mocks
    • IndexedDB (using fake-indexeddb)
    • Redis adapter (integration test behind env var; otherwise mocked)
    • Deduping under concurrency
    • Event emission on hits/misses/revalidate
  • Updated README with examples and adapter notes.

Potential Follow-Ups (separate issues)

  • DevTools overlay (small panel that logs cache hits/misses in dev).
  • Cache hydration/serialization for SSR frameworks.
  • ETag/Last-Modified awareness: use conditional requests during revalidation.

Additional Context

This feature aligns with modern caching patterns (e.g., SWR/RTK Query) and complements GhostCache’s multi-adapter design. It should significantly improve perceived performance while offering safe, explicit invalidation.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdocumentationImprovements or additions to documentationduplicateThis issue or pull request already existsenhancementNew feature or requestgood first issueGood for newcomershelp wantedExtra attention is neededquestionFurther information is requested

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions