Skip to content

RFC-7234 Revalidation (ETag/Last-Modified) + SWR / stale-if-error + In-Flight Request Coalescing + Cross-Context Invalidation #4

@hoangsonww

Description

@hoangsonww

Summary

Level-up GhostCache from “TTL + storage adapters” to a spec-aware HTTP cache with:

  1. Conditional revalidation via ETag / If-None-Match and Last-Modified / If-Modified-Since (RFC-7234),
  2. SWR (serve-stale-while-revalidate) and stale-if-error policies,
  3. In-flight request coalescing to prevent thundering herds,
  4. Cross-context invalidation (browser tabs via BroadcastChannel, Node via Redis pub/sub), and
  5. Tag-based invalidation (“surrogate keys”) for batch purges.

This keeps GhostCache lightweight but makes it production-grade for real APIs.


Motivation

  • Honor real HTTP caching semantics to reduce bandwidth and latency while staying consistent with origin freshness rules.
  • Serve instant responses even when entries are slightly stale, then refresh in the background.
  • Prevent dozens of simultaneous identical requests from stampeding the origin.
  • Invalidate coherently across tabs, workers, and servers.
  • Purge whole resource groups (e.g., user:42, products) at once.

Proposed API

enableGhostCache({
  // Existing:
  ttl?: number;
  persistent?: boolean;
  maxEntries?: number;
  storage?: "localStorage" | "sessionStorage" | "indexedDB" | "redis" | IStorageAdapter;

  // New:
  policy?: "ttl" | "http";           // 'http' uses Cache-Control/Expires + validators
  swr?: boolean;                     // serve stale then revalidate in background
  staleIfError?: boolean;            // if origin fails, serve stale fallback
  respectVary?: boolean;             // include Vary headers in cache-key normalization
  keyFn?: (req: NormalizedRequest) => string;  // custom cache key builder
  coalesce?: boolean;                // in-flight dedupe
  broadcast?: boolean;               // cross-tab/node invalidations
});

type SetOptions = { ttl?: number; tags?: string[] };
type InvalidateOptions = { key?: string; tags?: string[] };

export function revalidate(url: string, init?: RequestInit): Promise<Response>;
export function invalidate(opts: InvalidateOptions): Promise<void>;
export function setCache(key: string, value: any, opts?: SetOptions): Promise<void>;
export function getCache<T = any>(key: string): Promise<T | null>;

Notes

  • policy: "http" uses Cache-Control, Expires, and validators (ETag, Last-Modified) when present; otherwise falls back to ttl.

  • swr will serve stale (if within stale-while-revalidate window or TTL) and kick off a background revalidation.

  • staleIfError serves stale data on 5xx/network errors within a grace window.

  • broadcast: true enables:

    • Browser: BroadcastChannel("ghostcache"){ type: "invalidate", key|tags }
    • Node/Redis: Pub/Sub channel ghostcache:events with the same payload.

Implementation Outline

1) Request normalization & cache key

  • Normalize method, URL, sorted query params, relevant headers.
  • If origin responds with Vary, fold those header values into the key when respectVary is on.
  • Allow keyFn override.

2) Metadata stored with entries

interface StoredEntry {
  body: ArrayBuffer | string;       // raw or compressed
  status: number;
  headers: Record<string, string>;
  createdAt: number;                // ms
  maxAge?: number;                  // parsed from Cache-Control
  staleIfErrorSec?: number;         // parsed hint (if present) or config default
  etag?: string;
  lastModified?: string;
  tags?: string[];
}

3) Conditional revalidation

  • If entry is stale (per HTTP rules or TTL), send conditional request:

    • Prefer If-None-Match: <etag>; else If-Modified-Since: <lastModified>.
  • On 304 Not Modified: bump freshness, update headers, keep body.

  • On 200: replace entry.

4) SWR & stale-if-error

  • If swr and entry is stale but within grace, return stale immediately, then revalidate in background.
  • If origin errors and staleIfError applies, return latest stale entry.

5) In-flight coalescing

  • Keep a Map<cacheKey, Promise<Response>> of active fetches.
  • Next identical request awaits the same promise; clear when settled.

6) Cross-context invalidation

  • On invalidate({ key, tags }), remove from current store and broadcast.
  • Browser: BroadcastChannel.
  • Redis: simple publish/subscribe in RedisAdapter.

7) Tag-based invalidation

  • Store tags per entry; maintain tag→keys index in storage adapter when feasible (for Redis easily; for browser storages keep a compact index key).

8) Compression (optional, storage-aware)

  • Optionally compress large bodies for localStorage/indexedDB to fit quotas (e.g., browser-native CompressionStream when available; Node: brotli).

Axios & fetch integration

  • fetch: wrap global fetch; before-hit, check entry; after-response, store entry & validators.
  • Axios: request interceptor to check cache; response interceptor to store; If-None-Match/If-Modified-Since headers on revalidate path.

Edge Cases & Rules

  • Do not cache POST, PUT, etc. by default (opt-in via keyFn if users want).
  • Respect Cache-Control: no-store (never cache).
  • Respect Cache-Control: private in browser contexts; allow caching in app code at your own risk—document clearly.
  • Vary: * → treat as uncacheable.
  • Binary bodies: store as ArrayBuffer and reconstruct Response.

Testing Plan (Jest)

  • Use whatwg-fetch mock / msw (or Node nock) to simulate:

    • Initial 200 with ETag, subsequent 304 path.
    • Last-Modified fallback.
    • Cache-Control: max-age, stale-while-revalidate, no-store, Vary.
    • Network error + staleIfError fallback.
    • Coalescing: 10 parallel requests result in a single origin hit.
    • Cross-tab invalidation (BroadcastChannel shim).
    • Redis pub/sub invalidation in Node.

Docs & Examples

  • Guide: “Choosing ttl vs http policy” with flowcharts.
  • Recipe: SWR in React (tiny useGhost hook example).
  • Recipe: Tag invalidation after a mutation (invalidate({ tags: ['user:42'] })).
  • Recipe: Multi-tab coherence demo (open two tabs, show instant purge).

Backward Compatibility

  • Defaults preserve today’s behavior (policy: 'ttl', swr: false, coalesce: false, no broadcasting).
  • New features gated by options.

Tasks

Core

  • Parse/serialize metadata; cache-key normalization with optional Vary.
  • Conditional requests & 304 handling.
  • SWR + stale-if-error flow.
  • In-flight coalescing map.
  • Tag index & invalidation API.

Adapters

  • Browser storage: tag index keys + optional compression.
  • Redis adapter: pub/sub channel + tag sets (e.g., SADD ghost:tag:products <key>).

Cross-Context

  • BroadcastChannel integration + graceful fallback (no-op if unsupported).
  • Redis pub/sub wire-up in RedisAdapter.

Axios/Fetch

  • Interceptors/patch with conditional headers.
  • Unit tests for both paths.

DX

  • Docs + migration notes.
  • Examples (examples/react-swr, examples/node-redis).
  • Add flags to jest tests in CI.

Nice-to-have (post-merge)

  • CLI: ghost-cache ls/inspect/purge --tag products.
  • Metrics hooks: onCacheHit, onRevalidate, onPurge for plugging into logs.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdocumentationImprovements or additions to documentationenhancementNew 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