DEV Community

Vin Lookup
Vin Lookup

Posted on

Cache-Aside VIN Decodes So Misses Hit NHTSA Once and Hits Stay Honest About Age

A free VIN decode edge that always calls NHTSA wastes quota; one that serves cache forever lies about freshness. Stale-while-revalidate (covered elsewhere) serves soft-stale bodies while refreshing. ETag caching (elsewhere) skips unchanged representations. Negative cache (elsewhere) remembers invalid VINs. Stampede control and warmup (elsewhere) manage herds and prefetch. This post is different: classic cache-aside (lazy loading) -- on miss, fetch DecodeVinValues once, populate the cache, return the body; on hit, return the cached body with an honest age/freshness label; never invent specs on miss.

The goal is narrow: implement get-or-load for normalized VINs, single-populate on miss, expose cache age to the UI, and keep misses from becoming folklore cards. Cache-aside is deliberately boring infrastructure -- its value is correct loading and honest labeling, not clever speculation about what a VIN "should" contain.

Cache-aside vs SWR, ETag, negative, stampede, warmup

  • SWR -- serve soft-stale while revalidating in background
  • ETag -- conditional revalidation with validators
  • Negative cache -- remember failures / invalid VINs
  • Stampede control -- one refresher when many hit expiry together
  • Warmup -- populate before first user miss
  • Cache-aside -- read path loads on miss; writer is the read path (or explicit put after live success)

Cache-aside is the default lazy pattern. Pair it with stampede control when popular keys expire; do not confuse a simple miss-fill with SWR's soft-stale serve. On a cold miss you wait for live; on a warm hit you must still admit the body's age.

Get-or-load with honest age

export type CacheAsideEntry = {
  vinNormalized: string;
  body: string;
  storedAt: number;
  maxAgeMs: number;
};

export type CacheAsideStore = Map<string, CacheAsideEntry>;

export type CacheAsideResult = {
  body: string;
  hit: boolean;
  ageMs: number;
  source: "cache" | "live";
};

export type LiveDecode = (vin: string) => Promise<string>;

export function getAside(
  store: CacheAsideStore,
  vinNormalized: string,
  now = Date.now(),
): CacheAsideEntry | null {
  const e = store.get(vinNormalized);
  if (!e) return null;
  if (now - e.storedAt > e.maxAgeMs) {
    store.delete(vinNormalized);
    return null;
  }
  return e;
}

export function putAside(
  store: CacheAsideStore,
  vinNormalized: string,
  body: string,
  maxAgeMs = 86_400_000,
  now = Date.now(),
): CacheAsideEntry {
  const e: CacheAsideEntry = { vinNormalized, body, storedAt: now, maxAgeMs };
  store.set(vinNormalized, e);
  return e;
}

export async function getOrLoad(
  store: CacheAsideStore,
  vinNormalized: string,
  live: LiveDecode,
  maxAgeMs = 86_400_000,
  now = Date.now(),
): Promise<CacheAsideResult> {
  const hit = getAside(store, vinNormalized, now);
  if (hit) {
    return {
      body: hit.body,
      hit: true,
      ageMs: now - hit.storedAt,
      source: "cache",
    };
  }
  const body = await live(vinNormalized);
  putAside(store, vinNormalized, body, maxAgeMs, Date.now());
  return { body, hit: false, ageMs: 0, source: "live" };
}

export function freshnessLabel(r: CacheAsideResult): string {
  if (r.source === "live") {
    return "Decoded from live NHTSA (cache miss filled)";
  }
  const ageSec = Math.round(r.ageMs / 1000);
  return `Cache hit -- catalog body age ~${ageSec}s (not inventing newer specs)`;
}
Enter fullscreen mode Exit fullscreen mode

On live failure, do not put a folklore body. Leave the key missing so the next call retries, or use negative cache (separate tool) for known-invalid VINs. If many clients miss the same key together, wrap getOrLoad's live call in singleflight or stampede leadership so you still count one NHTSA fill -- cache-aside defines where the body lives; those tools define who may load it.

Honesty rules for hits and misses

Prefer:

  1. One live fill per miss (add singleflight/stampede if many waiters share the miss)
  2. Age labels on hits so UI does not say "live just now"
  3. TTL expiry deletes or treats as miss -- no eternal silent hits
  4. No write of partial invented fields when live returns sparse Results (partial payload honesty elsewhere)

Avoid serving a hit while claiming live completion, and avoid miss paths that invent Make from WMI charts. Emit metrics for hit ratio, miss latency, and average age-at-hit so you can tune maxAgeMs from data instead of folklore about how often vPIC changes.

Forbidden upgrades

  1. Populating cache with guessed catalog JSON when NHTSA fails
  2. Labeling every hit as "live NHTSA just now"
  3. Skipping TTL because "VIN decode never changes" (patterns and fields do evolve)
  4. Using cache-aside put to store negative failures as successful empty cars
  5. Warming by writing folklore for popular makes without a live body

Refuse those. Cache-aside speeds repeats; it does not mint specs.

Quick checks

import assert from "node:assert/strict";

const store: CacheAsideStore = new Map();
let liveCalls = 0;
const live: LiveDecode = async () => {
  liveCalls += 1;
  return JSON.stringify({ Results: [{ Make: "HONDA" }] });
};

const a = await getOrLoad(store, "1HGCM82633A004352", live);
assert.equal(a.hit, false);
assert.equal(a.source, "live");
assert.equal(liveCalls, 1);

const b = await getOrLoad(store, "1HGCM82633A004352", live);
assert.equal(b.hit, true);
assert.equal(b.source, "cache");
assert.equal(liveCalls, 1);
assert.ok(/Cache hit/i.test(freshnessLabel(b)));
assert.ok(!/live NHTSA just now/i.test(freshnessLabel(b)));

// expired entry becomes miss
const e = store.get("1HGCM82633A004352")!;
e.storedAt = Date.now() - e.maxAgeMs - 1;
const c = await getOrLoad(store, "1HGCM82633A004352", live);
assert.equal(c.hit, false);
assert.equal(liveCalls, 2);
Enter fullscreen mode Exit fullscreen mode

Review rule: cache-aside modules must fill from live on miss, label hit age honestly, and must not invent bodies on upstream failure.

Takeaway

Cache-aside VIN decodes hit NHTSA once per miss, store the real body, and serve repeats with an honest age label. Leave SWR, ETag, negative cache, stampede control, and warmup for their own jobs. Your free VIN edge stays fast when the cache is a shelf for sourced payloads -- never a factory for folklore catalog cards. Speed without age labels is how cached data pretends to be live.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)