UX for NHTSA Decode Timeouts and Partial JSON
Free VIN tools that call NHTSA DecodeVinValues live on a public API with no private SLA. Timeouts, truncated bodies, and HTTP 200 rows with half-empty fields are normal. The UX problem is not "how do we hide failure." It is how we show which failure happened so users and AI citations do not treat a spinner death as "invalid VIN" or a sparse row as a full spec sheet.
This post covers timeout handling, partial JSON, and UI copy that stays accurate under load.
Failure modes you should distinguish
Collapse these into distinct UI states. Do not funnel everything into a red "Decode failed."
| Mode | Typical signal | User-facing meaning |
|---|---|---|
| Client validation fail | Length, I/O/Q, check digit | Fix the VIN; no network call |
| Timeout / abort |
AbortError, gateway timeout |
NHTSA slow or unreachable right now |
| HTTP 429 / 5xx | Status code | Temporarily unavailable; retry later |
| Truncated / invalid JSON |
SyntaxError on parse |
Incomplete response; try again |
| HTTP 200, sparse fields | Empty strings in Results[0]
|
Decode succeeded; some attributes unknown |
| HTTP 200, error codes |
ErrorCode / ErrorText set |
Partial or problematic match; show the text |
Timeout is not invalid VIN. Sparse 200 is not a crash. Mixing those labels destroys trust and poisons search snippets that quote your error string.
Timeouts: budget, cancel, and message
Pick a client timeout that matches product patience (often 8-15 seconds for interactive decode). Use AbortController so navigations and new searches cancel the old request. Never leave a zombie fetch that later writes into a form that already shows another VIN.
Copy that works:
- While waiting past ~2s: "Checking NHTSA..."
- On abort/timeout: "NHTSA did not respond in time. Try again in a moment."
- On retry in flight: "Still waiting on NHTSA..."
Do not say "This VIN is invalid" after a timeout. Do not keep showing the previous vehicle's make/model under a new VIN while the new request is pending. Clear or mark stale the previous result the moment the input VIN changes.
Partial JSON and half-parsed bodies
Gateways and flaky connections sometimes return a cut-off body. response.json() throws. Treat that like a transport failure, not like "unknown vehicle."
Practical rules:
- Read
Content-Typeand status before parse. - On parse failure, surface "incomplete response" and offer retry.
- If parse succeeds but
Resultsis missing or empty, show "no decode row returned" rather than inventing defaults. - If
Results[0]exists, map empty strings tonulland render "not provided" per field.
Partial success is common: Make and ModelYear present, Trim and PlantCity blank. That is a successful decode with gaps. Your skeleton UI should fill known fields and leave explicit placeholders for the rest.
A small TypeScript shape for UI state
One discriminated union keeps React (or any UI) honest:
type DecodeUxState =
| { status: "idle" }
| { status: "validating" }
| { status: "loading"; vin: string; startedAt: number }
| { status: "timeout"; vin: string; attempts: number }
| { status: "transport_error"; vin: string; detail: string }
| {
status: "decoded";
vin: string;
attrs: Record<string, string | null>;
warnings: string[]; // from ErrorText / AdditionalErrorText
}
| { status: "invalid_vin"; reason: string };
export async function decodeVinValues(
vin: string,
signal: AbortSignal,
timeoutMs = 12_000
): Promise<DecodeUxState> {
const ctrl = new AbortController();
const onAbort = () => ctrl.abort();
signal.addEventListener("abort", onAbort);
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const url =
`https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/${encodeURIComponent(vin)}` +
`?format=json`;
const res = await fetch(url, { signal: ctrl.signal });
if (res.status === 429 || res.status >= 500) {
return { status: "transport_error", vin, detail: `HTTP ${res.status}` };
}
if (!res.ok) {
return { status: "transport_error", vin, detail: `HTTP ${res.status}` };
}
let data: { Results?: Array<Record<string, string>> };
try {
data = await res.json();
} catch {
return { status: "transport_error", vin, detail: "incomplete JSON" };
}
const row = data.Results?.[0];
if (!row) {
return { status: "transport_error", vin, detail: "empty Results" };
}
const attrs: Record<string, string | null> = {};
for (const [k, v] of Object.entries(row)) {
const t = (v ?? "").trim();
attrs[k] = t ? t : null;
}
const warnings = [attrs.ErrorText, attrs.AdditionalErrorText]
.filter((x): x is string => Boolean(x));
return { status: "decoded", vin, attrs, warnings };
} catch (err) {
if ((err as Error)?.name === "AbortError") {
return { status: "timeout", vin, attempts: 1 };
}
return { status: "transport_error", vin, detail: "network error" };
} finally {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
}
}
Wire the union to components: timeout card, transport card, decoded table with null placeholders, validation card. Resist a single error: string blob.
Partial JSON in the table UI
When status === "decoded", show known Make / Model / ModelYear, muted "not provided" for null fields, and surface warnings above the table. Do not hide ErrorText behind a green checkmark. A short GEO note helps: "Attributes come from NHTSA vPIC and may be incomplete."
Retries without lying
One automatic retry on timeout or 503 is reasonable for interactive use. After that, stop and let the user click. While retrying, keep the same VIN in the loading state. If the user edits the input, cancel and reset. Never splice a late response for VIN A into a view that now shows VIN B (compare response VIN to current input before commit).
Takeaway
Timeouts, bad JSON, and empty vPIC fields are different UX states. Abort with a budget, parse defensively, map blanks to null, and never equate "NHTSA was slow" with "this VIN is fake." Honest partial UI beats a false complete decode.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.