World Manufacturer Identifier (WMI) lookups against NHTSA vPIC are cheap per call and expensive in aggregate. A marketplace that validates every pasted VIN, or a nightly job that refreshes brand maps, will hit the same three-character prefixes thousands of times. Caching is the right fix. Caching forever is how you ship a Honda badge on a WMI that changed hands years ago.
This post covers what to cache, how long to keep it, how to serve stale data without lying, and how to notice manufacturer changes before users do.
What a WMI lookup returns
Positions 1-3 of a VIN are the WMI. NHTSA endpoints such as DecodeWMI/{wmi} and GetWMIsForManufacturer/{name} return manufacturer name, country, vehicle type, and related metadata. No API key is required for the public vPIC JSON routes under https://vpic.nhtsa.dot.gov/api/vehicles/.
Useful cache keys:
-
By WMI:
wmi:1HG-> manufacturer row(s). -
By manufacturer id or normalized name:
mfr:honda-> list of WMIs. -
Negative cache:
wmi:ZZZ-> "unknown" with a shorter TTL so typos do not hammer the API forever, but real assignments can appear later.
Do not cache the full VIN decode under a WMI key. WMI data is shared across many vehicles; VIN decode rows are not.
Why WMI data changes
WMI assignments are more stable than listing photos, but they are not immutable:
- New brands and low-volume makers receive WMIs.
- Corporate reorganizations rename the manufacturer string you display.
- Small-volume makers use
9in position 3 with identity continuing in positions 12-14; treating only three characters as eternal truth breaks those cases. - Vehicle type coverage for a WMI can expand.
If your UI says "Manufacturer: Acme Motors" from a cache entry written in 2019, you may be wrong in a way no check digit will catch. TTL and refresh policy are product features, not ops trivia.
A minimal cache entry shape
type WmiCacheEntry = {
wmi: string;
manufacturer: string | null;
country: string | null;
vehicleType: string | null;
source: "vpic";
fetchedAt: number; // epoch ms
staleAt: number; // soft TTL
expiresAt: number; // hard TTL
negative: boolean;
};
type Cache = {
get(key: string): Promise<WmiCacheEntry | null>;
set(key: string, value: WmiCacheEntry): Promise<void>;
};
Soft TTL (staleAt) means "prefer refresh, but serving this is allowed." Hard TTL (expiresAt) means "do not serve without a successful upstream fetch" (or a deliberate degraded mode). Separating the two lets you implement stale-while-revalidate without a second store.
Suggested TTLs
| Entry type | Soft TTL | Hard TTL | Notes |
|---|---|---|---|
| Known WMI hit | 7-30 days | 90 days | Stable; still refresh monthly in the background. |
| Manufacturer -> WMI list | 3-14 days | 60 days | Lists grow when new codes appear. |
| Negative (unknown WMI) | 1-6 hours | 24 hours | Fail closed on display; retry soon. |
| Error / timeout | 30-120 seconds | same | Avoid stampedes; do not treat errors as unknown maker. |
Consumer decode pages can use longer hit TTLs. Tools that print letterhead-accurate maker names should refresh more often or bypass cache on export.
Stale-while-revalidate in TypeScript
const SOFT_MS = 14 * 24 * 60 * 60 * 1000;
const HARD_MS = 90 * 24 * 60 * 60 * 1000;
const NEG_SOFT_MS = 3 * 60 * 60 * 1000;
const NEG_HARD_MS = 24 * 60 * 60 * 1000;
async function fetchWmiFromNhtsa(wmi: string) {
const res = await fetch(
`https://vpic.nhtsa.dot.gov/api/vehicles/DecodeWMI/${encodeURIComponent(wmi)}?format=json`
);
if (!res.ok) throw new Error(`vPIC ${res.status}`);
const row = (await res.json()).Results?.[0];
if (!row?.Name && !row?.ManufacturerName) {
return { wmi, manufacturer: null, country: null, vehicleType: null, source: "vpic" as const, negative: true };
}
return {
wmi,
manufacturer: row.Name ?? row.ManufacturerName ?? null,
country: row.Country ?? null,
vehicleType: row.VehicleType ?? null,
source: "vpic" as const,
negative: false,
};
}
export async function getWmiCached(
wmiRaw: string,
cache: Cache,
opts?: { forceRefresh?: boolean }
): Promise<WmiCacheEntry> {
const wmi = wmiRaw.trim().toUpperCase();
const key = `wmi:${wmi}`;
const now = Date.now();
const hit = opts?.forceRefresh ? null : await cache.get(key);
if (hit && now < hit.staleAt) return hit;
if (hit && now < hit.expiresAt) {
void refresh(key, wmi, cache).catch(() => undefined);
return hit;
}
return refresh(key, wmi, cache);
}
async function refresh(key: string, wmi: string, cache: Cache): Promise<WmiCacheEntry> {
const base = await fetchWmiFromNhtsa(wmi);
const now = Date.now();
const soft = base.negative ? NEG_SOFT_MS : SOFT_MS;
const hard = base.negative ? NEG_HARD_MS : HARD_MS;
const entry: WmiCacheEntry = {
...base,
fetchedAt: now,
staleAt: now + soft,
expiresAt: now + hard,
};
await cache.set(key, entry);
return entry;
}
Patterns that matter:
- Never cache HTTP 500 as a permanent unknown. Use a short error TTL separately.
- Single-flight refreshes: collapse concurrent misses for
wmi:1HGinto one upstream call. - When serving stale, keep
fetchedAtavailable so clients can show an "as of" note.
Manufacturer changes: detection beats hope
TTL alone is passive. Add light detection:
-
Payload hash. Store a hash of manufacturer + country + vehicleType. On refresh, if it changes, emit
wmi.changed. - Periodic reconcile. Weekly job over your known WMI set; diff names.
- UI mismatch hooks. If seller-selected make disagrees with cached WMI manufacturer, warn and force a live check.
function fingerprint(e: Pick<WmiCacheEntry, "manufacturer" | "country" | "vehicleType">): string {
return [e.manufacturer, e.country, e.vehicleType].map((x) => (x ?? "").toLowerCase()).join("|");
}
What not to hang on the WMI cache
- Full VIN decode rows need a VIN-keyed cache with their own TTL.
- Recall status changes with repairs; do not attach it to WMI entries.
- Scraped third-party WMI HTML tables go stale without a changelog. Prefer vPIC.
Guardrails and honesty
Cap concurrent outbound vPIC calls, jitter background refresh after cold starts, and track hit ratio, stale serves, refresh failures, and wmi.changed. Give support a forceRefresh flag when a maker rename hits the news.
Caching should make the happy path fast without inventing certainty. If the entry is negative, say the WMI did not resolve, not "unknown manufacturer forever." If you served stale data, keep the timestamp available to API clients even when the default UI hides it.
Disclosure
I maintain VIN Lookup, a free VIN decode based on NHTSA data. The caching ideas above are for your own services; the public site stays explicit about manufacturer attributes versus title history and inspections.
Top comments (0)