Negatively Caching Invalid VIN Responses So Bad Digits Do Not Hammer NHTSA
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-pa
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.