DEV Community

Vin Lookup
Vin Lookup

Posted on

Designing Cache Keys for VIN Decode Results (Normalize First)

A VIN decode cache only helps if two equivalent inputs hit the same entry. Paste noise, lower-case letters, and invisible whitespace create false misses: you pay NHTSA again, burn rate budget, and show the same make/model twice under different keys. The fix is boring and non-negotiable: normalize before you hash or stringify the key.

This post is about key shape for DecodeVinValues-style rows, not TTL policy and not stale-while-revalidate. Those matter, but a wrong key makes every TTL look like a bug.

What goes wrong without normalization

Users paste VINs from PDFs, SMS, and OCR. You might see:

  • Leading or trailing spaces
  • Lower-case letters (1hgcm82633a004352)
  • Soft hyphens or zero-width characters between groups
  • Accidental newlines from a multi-line paste

If your cache key is the raw string, 1HGCM82633A004352 and 1hgcm82633a004352 are different keys for the same vehicle. A marketplace that re-checks listings hourly will thrash the upstream API on the same seventeen characters.

Normalize first, then key

const VIN_CHARSET = /^[A-HJ-NPR-Z0-9]{17}$/;

/** Strip invisible noise, upper-case, keep ISO VIN alphabet only. */
export function normalizeVin(raw: string): string | null {
  const cleaned = raw
    .normalize("NFKC")
    .replace(/[\u200B-\u200D\uFEFF\u00AD]/g, "")
    .replace(/[\s\-._]/g, "")
    .toUpperCase();
  if (!VIN_CHARSET.test(cleaned)) return null;
  return cleaned;
}

export function decodeCacheKey(raw: string, schemaVersion = 1): string | null {
  const vin = normalizeVin(raw);
  if (!vin) return null;
  // Schema version lets you bust keys when your stored row shape changes.
  return `vpic:decode:v${schemaVersion}:${vin}`;
}
Enter fullscreen mode Exit fullscreen mode

Never cache under a key derived from a failed normalize. Invalid input should fail closed before Redis or memory maps grow junk.

Key components that belong (and do not)

Include:

  • A stable prefix (vpic:decode) so other product caches do not collide
  • A schema / response-shape version when you change which fields you persist
  • The normalized 17-character VIN

Usually omit:

  • User id (unless you store user-specific overlays separately)
  • Locale or UI theme (those belong in the presentation layer)
  • Request timestamp
  • Raw query strings from your own HTTP API
export type CachedDecode = {
  key: string;
  vin: string;
  row: Record<string, string>;
  fetchedAt: number;
};

export function buildEntry(
  raw: string,
  row: Record<string, string>,
  now = Date.now(),
): CachedDecode | null {
  const key = decodeCacheKey(raw);
  const vin = normalizeVin(raw);
  if (!key || !vin) return null;
  return { key, vin, row, fetchedAt: now };
}
Enter fullscreen mode Exit fullscreen mode

Store the normalized VIN inside the value too. Debugging a key alone is harder than reading vin on the payload during an incident.

Collisions and privacy

VINs are identifiers. Treat cache keys as sensitive in logs the same way you treat the VIN itself. Prefer truncated or hashed log fields:

import { createHash } from "node:crypto";

export function loggableKey(key: string): string {
  // Keep prefix readable; hash the VIN segment.
  const parts = key.split(":");
  const vin = parts[parts.length - 1] ?? "";
  const hash = createHash("sha256").update(vin).digest("hex").slice(0, 12);
  return `${parts.slice(0, -1).join(":")}:sha256=${hash}`;
}
Enter fullscreen mode Exit fullscreen mode

Do not put cleartext VINs in shared analytics events if your policy forbids it. The cache store itself still needs the real key for lookups; restrict who can dump Redis.

Multi-tenant and model-year edge cases

If you ever call DecodeVinValues with an optional model year override, that override is part of the semantic result. Fold it into the key explicitly:

export function decodeCacheKeyWithYear(
  raw: string,
  modelYear: number | null,
  schemaVersion = 1,
): string | null {
  const vin = normalizeVin(raw);
  if (!vin) return null;
  const yearPart = modelYear == null ? "auto" : String(modelYear);
  return `vpic:decode:v${schemaVersion}:${vin}:y=${yearPart}`;
}
Enter fullscreen mode Exit fullscreen mode

Mixing year-forced and year-auto results under one key will produce silent wrong cards when NHTSA resolves ambiguity differently.

Tests that catch key drift

import assert from "node:assert/strict";

const a = decodeCacheKey("1hgcm82633a004352");
const b = decodeCacheKey(" 1HGCM82633A004352\n");
assert.equal(a, b);
assert.equal(decodeCacheKey("1HGCM82633A00435"), null); // length
assert.equal(decodeCacheKey("1HGCM82633A00435I"), null); // illegal I
assert.notEqual(
  decodeCacheKeyWithYear("1HGCM82633A004352", null),
  decodeCacheKeyWithYear("1HGCM82633A004352", 2003),
);
Enter fullscreen mode Exit fullscreen mode

Add one regression test whenever you change normalizeVin. Cache bugs often ship as "weird intermittent rate limits" rather than obvious wrong makes.

Operational checklist

  • Normalize in one shared module used by HTTP handlers, workers, and CLI tools
  • Version the key when the persisted JSON shape changes
  • Document that lower-case and spaced inputs must not create distinct entries
  • Monitor cache hit rate after normalization; a low hit rate with messy keys is a self-inflicted DDOS of your own upstream budget

Takeaway

Cache keys for VIN decode results should be built from a normalized ISO VIN, a clear prefix, and an explicit schema version. Optional decode parameters that change the answer (like model year override) belong in the key. Everything else -- whitespace, case, hyphens -- must be erased before the key exists. Do that once, centrally, and your TTL and SWR layers finally measure what you intended.

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

Top comments (0)