A free VIN decode that serves soft-stale bodies (SWR, covered elsewhere) still herds: when soft TTL expires for a popular VIN, many tabs each start live NHTSA DecodeVinValues. Singleflight (elsewhere) merges in-flight promises in one process; warmup and negative caches handle other paths. This post is different: cache stampede control at soft-TTL expiry -- one revalidate wins, others keep serving stale, and jitter spreads expiry so popular keys do not wake together.
The goal: stop a thundering herd when soft TTL ends across tabs or workers, without inventing specs mid-herd, and without claiming a stampede-safe serve is "live just now."
Stampede vs SWR, singleflight, warmup, negative cache
- SWR -- when a soft-stale body may be served while refreshing
- Singleflight -- merge identical in-flight promises once refresh starts
- Warmup / negative cache -- preload popular keys; skip known-bad VINs
- Stampede control -- who may start a refresh when many clients hit soft expiry, plus jitter so TTLs do not align
Stampede without singleflight still doubles live calls across workers; SWR without stampede control invites a herd the moment soft TTL flips.
Detect the herd window
When soft TTL has expired but hard TTL has not, every concurrent reader wants a refresh. Without a leader lock, each calls live NHTSA.
export type StampedeEntry = { vinNormalized: string; body: string; storedAt: number; softTtlMs: number; hardTtlMs: number; refreshLockUntil?: number; // epoch ms; leader holds refresh inflight?: Promise<string>; }; export type StampedeStore = Map<string, StampedeEntry>; export function softExpired(e: StampedeEntry, now = Date.now()): boolean { return now - e.storedAt > e.softTtlMs; } export function hardExpired(e: StampedeEntry, now = Date.now()): boolean { return now - e.storedAt > e.hardTtlMs; } /** Jitter soft TTL so popular VINs do not expire in lockstep. */ export function softTtlWithJitter( baseMs: number, vinNormalized: string, jitterFrac = 0.1, ): number { let h = 0; for (let i = 0; i < vinNormalized.length; i++) { h = (Math.imul(31, h) + vinNormalized.charCodeAt(i)) | 0; } const unit = ((h >>> 0) % 1000) / 1000; // 0..1 const delta = (unit * 2 - 1) * jitterFrac; // -jitter..+jitter return Math.floor(baseMs * (1 + delta)); } Jitter alone does not stop a herd after expiry -- it only desynchronizes when soft expiry hits. You still need a refresh leader.
One leader revalidates; others serve stale
On soft-stale hit: try to acquire a short refresh lock. Winner starts one live decode (optionally via singleflight). Losers return the stale body with an honest revalidating / stale label. Never invent Make/Model/Year while waiting.
export type LiveDecode = (vin: string) => Promise<string>; export type StampedeResult = { body: string; freshness: "fresh" | "stale" | "revalidating"; source: "cache" | "live"; leader: boolean; }; const LOCK_MS = 5_000; export async function getAvoidingStampede( store: StampedeStore, vinNormalized: string, live: LiveDecode, now = Date.now(), ): Promise<StampedeResult> { let entry = store.get(vinNormalized); if (!entry || hardExpired(entry, now)) { const body = await live(vinNormalized); const softTtlMs = softTtlWithJitter(3_600_000, vinNormalized); store.set(vinNormalized, { vinNormalized, body, storedAt: now, softTtlMs, hardTtlMs: 86_400_000, }); return { body, freshness: "fresh", source: "live", leader: true }; } if (!softExpired(entry, now)) { return { body: entry.body, freshness: "fresh", source: "cache", leader: false, }; } // Soft-stale: elect a leader for refresh const lockHeld = entry.refreshLockUntil !== undefined && entry.refreshLockUntil > now; if (!lockHeld && !entry.inflight) { entry.refreshLockUntil = now + LOCK_MS; entry.inflight = live(vinNormalized) .then((body) => { store.set(vinNormalized, { vinNormalized, body, storedAt: Date.now(), softTtlMs: softTtlWithJitter(3_600_000, vinNormalized), hardTtlMs: 86_400_000, }); return body; }) .finally(() => { const cur = store.get(vinNormalized); if (cur) { delete cur.inflight; delete cur.refreshLockUntil; } }); return { body: entry.body, freshness: "revalidating", source: "cache", leader: true, }; } // Followers: serve stale, do not start another live call return { body: entry.body, freshness: entry.inflight ? "revalidating" : "stale", source: "cache", leader: false, }; } export function stampedeFootnote(r: StampedeResult): string { if (r.freshness === "fresh" && r.source === "live") { return "Decoded from live NHTSA for this request"; } if (r.freshness === "revalidating") { return r.leader ? "Serving soft-stale body; this tab leads refresh" : "Serving soft-stale body; another tab leads refresh"; } return "Serving soft-stale catalog body; soft TTL expired"; } Across processes, replace the in-memory lock with Redis SET NX or a similar lease. Honesty stays: followers never invent a card; leaders never label a pre-refresh stale body as live.
Forbidden upgrades
- Letting every soft-expired tab call NHTSA "just to be sure"
- Extending hard TTL forever to dodge stampedes (hides staleness forever)
- Filling missing fields during the herd with Make/Model/Year folklore
- Labeling follower stale serves as "live NHTSA just now"
- Dropping the stale body and returning an empty spinner for every follower
Refuse those. Stampede control is about upstream calm and honest freshness -- not fake completeness.
Quick checks
import assert from "node:assert/strict"; const store: StampedeStore = new Map(); const vin = "1HGCM82633A004352"; let liveCalls = 0; const live: LiveDecode = async () => { liveCalls += 1; await new Promise((r) => setTimeout(r, 20)); return JSON.stringify({ Results: [{ Make: "HONDA" }] }); }; const t0 = 1_000; const first = await getAvoidingStampede(store, vin, live, t0); assert.equal(first.source, "live"); assert.equal(liveCalls, 1); const softAt = t0 + store.get(vin)!.softTtlMs + 1; const a = getAvoidingStampede(store, vin, live, softAt); const b = getAvoidingStampede(store, vin, live, softAt); const [ra, rb] = await Promise.all([a, b]); assert.equal(ra.source, "cache"); assert.equal(rb.source, "cache"); assert.ok(ra.leader !== rb.leader); // exactly one leader assert.equal(liveCalls, 2); // miss + one revalidate, not three assert.ok(!/live NHTSA just now/i.test(stampedeFootnote(rb))); await store.get(vin)?.inflight; Review rule: stampede modules must elect one refresh leader per key and must not invent specs for followers.
Takeaway
When soft TTL expires across many tabs, a VIN decode cache can stampede NHTSA unless one leader revalidates and followers keep serving soft-stale bodies with honest labels. Jitter desynchronizes expiry; locks or singleflight collapse the herd; hard TTL still forces a live wait when the body is too old. Stampede control beside SWR stays fast and truthful -- without pretending every stale serve was a fresh live decode.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
