A free VIN decode that always forwards every paste to live NHTSA DecodeVinValues pays upstream cost when users typo a check digit, paste seventeen zeros, or retry the same invalid string. Positive ETag caches and make-pattern warmup (covered elsewhere) store successful bodies. This post is different: a negative cache for VINs you already know are invalid or that vPIC already refused, so bad digits do not hammer NHTSA on every retry.
The goal is honest short-circuit: remember "this normalized VIN is not worth another upstream call for a while," show a clear invalid/error state, and never invent a successful decode card from a negative entry.
Negative cache vs positive caches
Use a negative cache when:
- Client-side checks (length, charset, check digit) already fail before HTTP
- Upstream returned a clear "no decode / error / empty Results" for that VIN
- The same bad paste repeats within a short TTL (fat-finger retries, bot loops)
Do not use negative cache to store successful DecodeVinValues bodies (ETag / warmup territory), invent folklore Make/Model/Year, suppress live calls forever (bound TTL), or treat 429/outages as permanent "invalid VIN."
When the entry expires or the failure was transient (5xx, timeout), allow a live retry. Client-invalid shapes can use a longer TTL than soft upstream empties.
Classify before you store
Separate client-invalid (never call upstream) from upstream-negative (you called, got a clear no-decode). Both can live in one map keyed by normalized VIN, with different reasons and TTLs.
export type NegReason = | "client-length" | "client-charset" | "client-check-digit" | "upstream-empty" | "upstream-error"; export type NegEntry = { vinNormalized: string; reason: NegReason; storedAt: number; // epoch ms ttlMs: number; message: string; // user-safe, no invented specs }; export type NegStore = Map<string, NegEntry>; const CLIENT_TTL_MS = 60 * 60 * 1000; // 1h for obvious typos const UPSTREAM_TTL_MS = 15 * 60 * 1000; // 15m for empty/error bodies export function normalizeVin(raw: string): string { return raw.trim().toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ""); } export function clientInvalidReason(vin: string): NegReason | null { if (vin.length !== 17) return "client-length"; if (!/^[A-HJ-NPR-Z0-9]{17}$/.test(vin)) return "client-charset"; // Check-digit algorithm omitted for brevity; return "client-check-digit" on fail return null; } export function putNegative( store: NegStore, vinNormalized: string, reason: NegReason, message: string, nowMs = Date.now(), ): NegEntry { const ttlMs = reason.startsWith("client-") ? CLIENT_TTL_MS : UPSTREAM_TTL_MS; const entry: NegEntry = { vinNormalized, reason, storedAt: nowMs, ttlMs, message, }; store.set(vinNormalized, entry); return entry; } export function getNegative( store: NegStore, vinNormalized: string, nowMs = Date.now(), ): NegEntry | null { const hit = store.get(vinNormalized); if (!hit) return null; if (nowMs - hit.storedAt >= hit.ttlMs) { store.delete(vinNormalized); return null; } return hit; } Never put a successful body into this map. Never upgrade a negative hit into Make/Model/Year rows.
Decode path with short-circuit
export type DecodeOutcome = | { kind: "negative"; entry: NegEntry } | { kind: "live"; body: Record<string, string | null> } | { kind: "transient-error"; status: number }; export async function decodeWithNegCache( store: NegStore, rawVin: string, fetchLive: (vin: string) => Promise<{ ok: boolean; status: number; body: Record<string, string | null> | null; empty: boolean; }>, nowMs = Date.now(), ): Promise<DecodeOutcome> { const vin = normalizeVin(rawVin); const cached = getNegative(store, vin, nowMs); if (cached) return { kind: "negative", entry: cached }; const clientReason = clientInvalidReason(vin); if (clientReason) { const entry = putNegative( store, vin, clientReason, "VIN failed local validation -- not sent to NHTSA", nowMs, ); return { kind: "negative", entry }; } const live = await fetchLive(vin); if (!live.ok && live.status >= 500) { return { kind: "transient-error", status: live.status }; } if (!live.ok || live.empty || !live.body) { const entry = putNegative( store, vin, live.ok ? "upstream-empty" : "upstream-error", "No decode available from vPIC for this VIN", nowMs, ); return { kind: "negative", entry }; } return { kind: "live", body: live.body }; } Transient 5xx stays out of the negative store (or uses a very short TTL if you must). That keeps outages from branding a good VIN as permanently invalid.
Forbidden upgrades
Product pressure often asks for:
- Serving a fake "success" card from a negative hit so the UI looks complete
- Infinite TTL so one empty response never rechecks after catalog updates
- Collapsing rate-limit 429 into "invalid VIN" negatives
- Mixing negative keys with positive ETag bodies in one undifferentiated blob
Refuse those. Negative cache is a courtesy throttle and UX shortcut -- not a second invent-specs path.
export function assertNoNegInvent(moduleSource: string): void { const banned = [ /invent.*(make|model|year)/i, /negative.*success.?card/i, /forever.?invalid/i, /treat.?429.?as.?invalid/i, ]; for (const re of banned) { if (re.test(moduleSource)) { throw new Error(`Negative cache must not invent decode cards: ${re}`); } } } export function uiMessage(entry: NegEntry): string { return entry.message; } UI copy that stays honest
Prefer:
- "VIN failed local validation -- not sent to NHTSA"
- "No decode available from vPIC for this VIN (cached briefly)"
- "Temporary upstream error -- try again shortly" for transient failures
Avoid Make/Model/Year from memory on negative hits, "invalid forever" without TTL disclosure, and silent success styling on a negative entry.
Quick checks
import assert from "node:assert/strict"; const store: NegStore = new Map(); const short = normalizeVin("123"); assert.equal(clientInvalidReason(short), "client-length"); const neg = putNegative( store, short, "client-length", "VIN failed local validation -- not sent to NHTSA", 1_000, ); assert.equal(getNegative(store, short, 1_000)?.reason, "client-length"); assert.equal(getNegative(store, short, 1_000 + CLIENT_TTL_MS)?.reason, undefined); assert.equal(uiMessage(neg).includes("Make"), false); assert.ok(!/standard|verified|package/i.test(uiMessage(neg))); Review rule: negative-cache modules must not invent successful decode fields. Bound TTL; separate transient errors from invalid VINs.
Takeaway
A negative cache stops bad digits and known empty upstream results from hammering NHTSA on every retry. Store reason + TTL, short-circuit with honest UI copy, and leave successful bodies to ETag and warmup paths. Invalid pastes stay remembered briefly -- never upgraded into invented catalog rows.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
