A free VIN decode that always waits for live NHTSA DecodeVinValues on every repeat paste feels slow even when the catalog barely changes. ETag caches, negative caches, and warmup (covered elsewhere) solve other problems. This post is different: stale-while-revalidate (SWR) -- serve a slightly stale cached decode immediately, refresh in the background, and label freshness honestly so the UI never pretends a background refresh already finished.
The goal is narrow: return a cached body past soft TTL while a revalidate runs, expose fresh vs stale vs revalidating to callers, and never invent specs or claim "live NHTSA just now" when you served stale.
SWR vs ETag, negative cache, and warmup
Use SWR when:
- Soft TTL expired but hard TTL has not -- body may still be useful
- A repeat lookup should paint fast while a background DecodeVinValues runs
- Callers can show "catalog snapshot; refreshing..." without blocking
Do not confuse SWR with:
- ETag / If-None-Match conditional fetches (validator equality, not soft-stale serve)
- Negative cache of invalid VINs (no successful body to serve stale)
- Make-pattern warmup (prefetch popular keys before first user hit)
SWR is about freshness semantics on a successful cached body. When hard TTL expires, drop the entry and require a blocking live fetch. Never serve a negative entry as a successful card.
Soft TTL, hard TTL, and honesty labels
Store soft and hard windows. Soft expiry allows stale serve + background revalidate. Hard expiry forces a live wait. Label every response so the UI cannot lie.
export type Freshness = "fresh" | "stale" | "revalidating" | "miss";
export type SwrEntry = {
vinNormalized: string;
body: string;
storedAt: number; // epoch ms
softTtlMs: number;
hardTtlMs: number;
revalidating?: Promise<string>;
};
export type SwrStore = Map<string, SwrEntry>;
export type SwrResult = {
body: string | null;
freshness: Freshness;
source: "cache" | "live" | "missing";
};
const SOFT_TTL_MS = 60 * 60 * 1000; // 1h soft
const HARD_TTL_MS = 24 * 60 * 60 * 1000; // 24h hard
export function ageMs(entry: SwrEntry, now = Date.now()): number {
return now - entry.storedAt;
}
export function classify(entry: SwrEntry, now = Date.now()): Freshness {
const age = ageMs(entry, now);
if (age > entry.hardTtlMs) return "miss";
if (age <= entry.softTtlMs) return "fresh";
if (entry.revalidating) return "revalidating";
return "stale";
}
A fresh body may still be hours old relative to wall clock -- that is fine if soft TTL allows it. What is not fine is labeling a soft-stale serve as "live decode completed just now."
Serve stale, revalidate once
On soft-stale hit: return the cached body immediately, start one background revalidate (coalesce concurrent callers onto the same promise), and surface revalidating so the UI can footnote.
export type LiveDecode = (vin: string) => Promise<string>;
export async function getWithSwr(
store: SwrStore,
vinNormalized: string,
live: LiveDecode,
now = Date.now(),
): Promise<SwrResult> {
const entry = store.get(vinNormalized);
if (!entry || ageMs(entry, now) > entry.hardTtlMs) {
const body = await live(vinNormalized);
store.set(vinNormalized, {
vinNormalized,
body,
storedAt: now,
softTtlMs: SOFT_TTL_MS,
hardTtlMs: HARD_TTL_MS,
});
return { body, freshness: "fresh", source: "live" };
}
const freshness = classify(entry, now);
if (freshness === "fresh") {
return { body: entry.body, freshness: "fresh", source: "cache" };
}
// Soft-stale: serve body, kick revalidate if not already running
if (!entry.revalidating) {
entry.revalidating = live(vinNormalized)
.then((body) => {
store.set(vinNormalized, {
vinNormalized,
body,
storedAt: Date.now(),
softTtlMs: SOFT_TTL_MS,
hardTtlMs: HARD_TTL_MS,
});
return body;
})
.finally(() => {
const cur = store.get(vinNormalized);
if (cur) delete cur.revalidating;
});
}
return {
body: entry.body,
freshness: "revalidating",
source: "cache",
};
}
export function freshnessFootnote(r: SwrResult): string {
switch (r.freshness) {
case "fresh":
return r.source === "live"
? "Decoded from live NHTSA for this request"
: "Cached catalog body within soft TTL";
case "stale":
return "Serving stale catalog body; soft TTL expired";
case "revalidating":
return "Serving stale catalog body while refreshing from NHTSA";
case "miss":
return "No usable cache entry; live decode required";
}
}
Never rewrite the stale body while revalidate is in flight. Swap only when the live promise resolves. If revalidate fails, keep serving the prior body with an honest error footnote -- do not invent Make/Model/Year to "heal" the miss.
Forbidden upgrades
Product pressure often asks for:
- Labeling every SWR serve as "live NHTSA just now"
- Extending hard TTL forever so stale bodies never expire
- Serving negative-cache failures as successful decode cards
- Inventing missing catalog fields while a revalidate is pending
- Hiding the revalidating footnote so cards look always-fresh
Refuse those. Speed without honesty is a lie about freshness.
Quick checks
import assert from "node:assert/strict";
const store: SwrStore = new Map();
const vin = "1HGCM82633A004352";
let liveCalls = 0;
const live: LiveDecode = async () => {
liveCalls += 1;
return JSON.stringify({ Results: [{ Make: "HONDA" }] });
};
const first = await getWithSwr(store, vin, live, 1_000);
assert.equal(first.freshness, "fresh");
assert.equal(first.source, "live");
assert.equal(liveCalls, 1);
const softStaleAt = 1_000 + SOFT_TTL_MS + 1;
const second = await getWithSwr(store, vin, live, softStaleAt);
assert.equal(second.source, "cache");
assert.equal(second.freshness, "revalidating");
assert.ok(/stale|refreshing/i.test(freshnessFootnote(second)));
assert.ok(!/live NHTSA just now/i.test(freshnessFootnote(second)));
// Concurrent soft-stale callers share one revalidate
const third = await getWithSwr(store, vin, live, softStaleAt);
assert.equal(third.freshness, "revalidating");
await store.get(vin)!.revalidating;
assert.equal(liveCalls, 2); // one live + one revalidate, not three
Review rule: SWR modules must expose freshness labels and must not claim live completion for stale serves.
Takeaway
Stale-while-revalidate keeps repeat VIN lookups fast by serving a soft-stale catalog body while NHTSA refreshes in the background. Pair soft and hard TTLs, coalesce revalidates, and label every response so users never confuse a stale snapshot with a just-completed live decode. Speed and honesty travel together; ETag, negative, and warmup caches stay in their own lanes.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)