Batch decode is where product UX either feels like a dashboard or like a frozen spinner. NHTSA DecodeVinValuesBatch lets you send multiple VINs in one upstream call, but the response is not "all green or all red." Some rows decode cleanly, some return empty cores, and some never left your client because of local validation.
This post is about progress and partial-failure UX for batch jobs: determinate progress when you can, honest per-VIN outcomes when you cannot, and UI copy that does not claim a full success when three of fifty rows are wrong.
Batch is not a single promise
A batch job has at least three layers:
- Client queue -- normalize, length-check, check-digit filter before any network
-
Upstream call(s) -- one
DecodeVinValuesBatchor several chunks under a size cap - Row mapping -- each VIN becomes success, soft-empty, or hard-fail in your domain model
Progress that only tracks HTTP round-trips lies to the user. They care about their VIN list, not your socket.
Model the job explicitly
type RowStatus = "queued" | "running" | "ok" | "empty" | "invalid" | "error";
type BatchRow = {
id: string;
vinNormalized: string;
status: RowStatus;
make?: string | null;
model?: string | null;
errorCode?: string;
};
type BatchJob = {
id: string;
rows: BatchRow[];
startedAt: number;
finishedAt?: number;
};
export function progressOf(job: BatchJob): {
done: number;
total: number;
ok: number;
failed: number;
ratio: number;
} {
const total = job.rows.length;
const terminal = new Set(["ok", "empty", "invalid", "error"]);
const done = job.rows.filter((r) => terminal.has(r.status)).length;
const ok = job.rows.filter((r) => r.status === "ok").length;
const failed = job.rows.filter((r) =>
r.status === "invalid" || r.status === "error"
).length;
return { done, total, ok, failed, ratio: total ? done / total : 0 };
}
Separate empty (upstream responded, core fields missing) from error (timeout, 5xx, parse failure). Sellers and dealers treat those differently: empty often means "try again later or check the VIN," error means "our pipeline broke."
Determinate progress that matches reality
Show a bar bound to done / total, not to elapsed time. When you chunk batches of 20:
- Advance
runningfor the active chunk - Flip each row to a terminal status as soon as that chunk returns
- Never jump from 0% to 100% after a long silent wait
If the upstream API is all-or-nothing per request, still update local invalids immediately (check-digit failures) so the bar moves before the network returns. Early motion signals "we understood your file."
Partial failure is a first-class screen
When the job finishes with mixed outcomes, do not toast only "Batch complete." Lead with counts:
export function batchSummaryCopy(p: ReturnType<typeof progressOf>): string {
if (p.failed === 0 && p.ok === p.total) {
return `Decoded ${p.ok} of ${p.total} VINs.`;
}
if (p.ok === 0) {
return `Could not decode any of ${p.total} VINs. Review errors below.`;
}
return `Decoded ${p.ok} of ${p.total}. ${p.failed} need attention.`;
}
UI patterns that reduce support mail:
- Sticky summary chips: OK / Empty / Invalid / Error
- Sort failed rows to the top after completion
- Per-row reason in plain language (
Check digit failed,NHTSA timeout,No make/model returned) - Export CSV of failures only so ops can re-queue without re-uploading successes
Avoid the fake 100% trap
A progress bar at 100% while you still map rows or write cache entries trains users to close the tab early. Keep a short finalizing state after the last chunk:
-
status: "finalizing"on the job while you persist - Bar stays at 99% or shows an indeterminate pulse for under a second
- Only then mark
finishedAtand unlock Download / Apply
For very large lists, stream row updates over SSE or poll a job endpoint. The progress component should subscribe to BatchJob, not to a single fetch promise.
Cancellation and retry
Allow cancel between chunks, not mid-row corruption:
export async function runChunks(
job: BatchJob,
chunkSize: number,
decodeChunk: (vins: string[]) => Promise<BatchRow[]>,
isCancelled: () => boolean,
): Promise<BatchJob> {
for (let i = 0; i < job.rows.length; i += chunkSize) {
if (isCancelled()) break;
const slice = job.rows.slice(i, i + chunkSize);
slice.forEach((r) => {
if (r.status === "queued") r.status = "running";
});
const vins = slice.map((r) => r.vinNormalized);
const updated = await decodeChunk(vins);
updated.forEach((u, idx) => {
slice[idx].status = u.status;
slice[idx].make = u.make;
slice[idx].model = u.model;
slice[idx].errorCode = u.errorCode;
});
}
job.finishedAt = Date.now();
return job;
}
Retry should re-queue only error rows (and optionally empty), never silently re-hit NHTSA for rows already ok. Show the retry count so users know they are not in a loop.
Accessibility and calm motion
- Prefer
aria-valuenowon the progressbar with live region text for the summary - Do not rely on color alone for fail vs empty (icon + label)
- Cap animation: one progress update per row is enough; no confetti on partial failure
Product rules
- Progress = terminal rows / total, including local invalids.
- Mixed results get a mixed summary, never a green check alone.
- Empty and error are different statuses in the model and the UI.
- Cancel between chunks; retry only non-success rows.
- Finalizing is visible so 100% means durable results.
Takeaway
DecodeVinValuesBatch succeeds as a product when users can watch the queue, trust the percentage, and act on the failures without guessing. Model each VIN as a row with a clear status, drive the bar from those statuses, and treat partial failure as the normal end state -- not an edge case you hide behind "Done."
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)