Why Not to Autocomplete VINs from Partial Input Against NHTSA
Product managers love typeahead. Users type three characters of a city and a dropdown appears. So someone eventually asks: can we autocomplete VINs the same way? Partial string in, NHTSA suggestions out, picker UI, done.
Product managers love typeahead. Users type three characters of a city and a dropdown appears. So someone eventually asks: can we autocomplete VINs the same way? Partial string in, NHTSA suggestions out, picker UI, done.
For VINs that idea is a trap. A Vehicle Identification Number is not a prefix-searchable directory like street names. Hitting vPIC (or any decode API) on every keystroke wastes quota, leaks incomplete identifiers into logs, and trains users to expect completions the public data model cannot provide.
This post explains why VIN autocomplete against NHTSA is the wrong UX, what to do instead, and a small TypeScript guard that blocks premature network calls.
VINs are identifiers, not searchable titles
NHTSA DecodeVinValues expects a full 17-character VIN (after your normalization rules). There is no official "VIN suggest" endpoint that returns a ranked list of completions for 1HG or 1HGCM. The first three characters are a World Manufacturer Identifier; they describe a maker region pattern, not a unique vehicle.
Partial input therefore cannot resolve to "the car the user meant" without guessing among millions of serial combinations. Autocomplete that invents those endings is fiction dressed as helpfulness.
What goes wrong in practice
- Quota and rate limits -- debounce or not, each keystroke path that reaches the network multiplies DecodeVinValues traffic by roughly the average typed length. Free products feel this first.
- False confidence -- a dropdown of "likely VINs" implies the list is authoritative. Users pick a wrong completion and trust the decode of a vehicle they never owned.
-
Privacy and logging -- partial VINs still identify inventory and buyers. Streaming them to analytics on every
inputevent expands your retention surface for no decode value. - Error UX thrash -- positions 1-8 can look valid while the check digit and VIS are unfinished. Live decode attempts produce flicker: invalid, invalid, success.
- Mobile cost -- dealers on cellular should not fire seven failed API calls while pasting slowly from a paper packet.
Validate locally; decode once
Treat the field like a one-shot identifier entry: normalize, enforce charset and length, optionally check digit, then decode once when the value is complete (submit, explicit button, or blur with length 17).
const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;
export type VinFieldPhase =
| { status: "empty" }
| { status: "editing"; length: number }
| { status: "ready"; vin: string }
| { status: "blocked"; reason: "charset" | "check_digit" };
export function phaseForVinInput(raw: string): VinFieldPhase {
const vin = raw.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
if (!vin) return { status: "empty" };
if (/[IOQ]/.test(vin)) return { status: "blocked", reason: "charset" };
if (vin.length < 17) return { status: "editing", length: vin.length };
if (!VIN_RE.test(vin)) return { status: "blocked", reason: "charset" };
// Optional: run check-digit here and return blocked if it fails
return { status: "ready", vin };
}
export function shouldCallNhtsa(phase: VinFieldPhase): boolean {
return phase.status === "ready";
}
Wire shouldCallNhtsa into your submit handler or a debounced blur that only fires when ready. Never attach DecodeVinValues to onChange for lengths under 17.
What you can show while they type (without NHTSA)
Local feedback is fine and cheap:
| Length | Honest UI |
|---|---|
| 0 | Placeholder only |
| 1-2 | "VIN needs 17 characters" |
| 3 | Optional static WMI country hint from your local table |
| 4-16 | Progress n / 17, charset errors, no decode |
| 17 | Enable Lookup; then call NHTSA |
A local WMI map for the first three characters is autocomplete-adjacent but different: you are not suggesting the rest of the VIN. You are labeling a prefix you already store. Keep that label quiet and never present it as a vehicle match.
If stakeholders still want "suggestions"
Push back with a concrete alternative: recent lookups in this session (user's own history), or paste-from-clipboard assist, or OCR of a photo. Those help real workflows without pretending NHTSA is a typeahead index.
If you must prototype a dropdown, fill it only from data you own (fleet inventory, the dealer's stock feed). Label it "Your inventory" and never call it NHTSA autocomplete.
Debounce is not a license to call early
Debouncing onChange by 300ms feels responsible. It still fires DecodeVinValues for 1HGCM8263, then 1HGCM82633, then the full VIN -- three network trips for one user intent. Debounce reduces chatter; it does not create a valid identifier.
If you already have a shared request layer, put the length gate inside it so UI authors cannot bypass the rule:
export async function lookupVinOrThrow(raw: string): Promise<unknown> {
const phase = phaseForVinInput(raw);
if (!shouldCallNhtsa(phase)) {
throw new Error("VIN_NOT_READY");
}
// ... fetch DecodeVinValues for phase.vin only
return { vin: phase.status === "ready" ? phase.vin : null };
}
Unit-test the gate with lengths 0, 3, 16, and 17. Product regressions show up as "autocomplete experiments" that reopen partial fetches.
Analytics without partial-VIN cardinality
Product wants funnels: started typing, reached 17, decoded OK. Emit coarse events (vin_edit_started, vin_ready, vin_decode_ok) with lengths and boolean flags. Do not send the partial string. High-cardinality VIN prefixes in analytics look like a free inventory scrape waiting to happen.
Takeaway
Do not autocomplete VINs from partial input against NHTSA. DecodeVinValues is a full-identifier API; prefixes are not completions. Keep keystroke work local, call the network once at 17 valid characters, and satisfy "helpful typing" with progress, charset checks, and optional local WMI hints -- not invented VIN endings.
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.