A free VIN decode often pairs a per-request timeout with a submit button users can mash. Timeout budgets are good. The trap is a late response from a timed-out call still winning the UI after the user already submitted a different VIN -- or after a newer attempt for the same VIN already painted a fresher error.
This post is about the overlap race: request generations, commit guards, and clearing stale timers so a timeout path and a new submit cannot both mutate the same decode card.
Cancellation and retries are covered elsewhere. Here the focus is timeout + new submit: two clocks, one screen.
The failure mode
Typical buggy sequence:
- User submits VIN A. You start
fetchwith a 8s timeout timer. - At 7.9s the user pastes VIN B and submits again. You start a second fetch (maybe you aborted A, maybe you only "gave up" in the UI).
- A's timeout handler fires: sets
error = "Decode timed out"andloading = false. - B's response arrives (or B's own timeout). Depending on order, the card shows A's timeout under B's VIN, or B's Make/Model briefly then flips back to A's stale timeout banner.
- Analytics count a timeout for the wrong generation; support sees screenshots that never match logs.
Aborting fetch alone is not enough if a timeout setTimeout still closes over setState without a generation check. AbortSignal alone is incomplete if a proxy returns after a sibling request already started.
One generation per submit
Bump a monotonic generation (or UUID) on every successful submit of a normalized VIN. Store it on the in-flight handle. Timeout handlers, fetch .then, and .catch must refuse to commit unless handle.generation === currentGeneration.
export type DecodeRow = Record<string, string>;
export type DecodeUiState = {
vin: string | null;
row: DecodeRow | null;
error: string | null;
loading: boolean;
generation: number;
};
export type InFlight = {
generation: number;
vin: string;
controller: AbortController;
timer: ReturnType<typeof setTimeout>;
};
const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;
export function normalizeVin(raw: string): string {
return raw.trim().toUpperCase().replace(/[\s\-._]/g, "");
}
export async function decodeVinValues(
vin: string,
signal: AbortSignal,
): Promise<DecodeRow> {
const url =
"https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/" +
encodeURIComponent(vin) +
"?format=json";
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`vPIC HTTP ${res.status}`);
const body = (await res.json()) as { Results?: DecodeRow[] };
const row = body.Results?.[0];
if (!row) throw new Error("vPIC returned no Results row");
return row;
}
export function createDecodeSession(
publish: (s: DecodeUiState) => void,
timeoutMs = 8_000,
) {
let generation = 0;
let inFlight: InFlight | null = null;
function isLive(gen: number): boolean {
return gen === generation && inFlight?.generation === gen;
}
function clearInFlight(gen: number) {
if (inFlight && inFlight.generation === gen) {
clearTimeout(inFlight.timer);
inFlight = null;
}
}
function submit(raw: string) {
const vin = normalizeVin(raw);
if (!VIN_RE.test(vin)) {
publish({
vin: null,
row: null,
error: "Enter a valid 17-character VIN",
loading: false,
generation,
});
return;
}
// Abort and invalidate any previous attempt -- including its timeout.
if (inFlight) {
clearTimeout(inFlight.timer);
inFlight.controller.abort();
inFlight = null;
}
generation += 1;
const gen = generation;
const controller = new AbortController();
const timer = setTimeout(() => {
if (!isLive(gen)) return;
controller.abort();
clearInFlight(gen);
publish({
vin,
row: null,
error: "Decode timed out. Try again.",
loading: false,
generation: gen,
});
}, timeoutMs);
inFlight = { generation: gen, vin, controller, timer };
publish({
vin,
row: null,
error: null,
loading: true,
generation: gen,
});
decodeVinValues(vin, controller.signal)
.then((row) => {
if (!isLive(gen)) return;
clearInFlight(gen);
const responseVin = normalizeVin(String(row.VIN ?? vin));
if (responseVin !== vin) return;
publish({
vin,
row,
error: null,
loading: false,
generation: gen,
});
})
.catch((err: unknown) => {
if (!isLive(gen)) return;
clearInFlight(gen);
if (controller.signal.aborted) return; // timeout or newer submit
const message =
err instanceof Error ? err.message : "Decode failed";
publish({
vin,
row: null,
error: message,
loading: false,
generation: gen,
});
});
}
return { submit };
}
Critical details: bump generation before the new timer; clear the previous timer explicitly; require isLive(gen) in timeout and fetch paths; match response VIN; never let an old .catch overwrite a newer loading state.
Why "only AbortController" still races
If you abort A when B starts but A's timeout callback is already queued on the event loop, that callback can still run. Without a generation check it calls setError("timed out") for a generation the UI no longer owns. Conversely, if B times out and A's zombie response arrives because you forgot to abort, Make/Model for A paints under B.
Treat timeout and abort as one ownership bundle: one InFlight record, one generation, one timer, one controller.
UI rules that keep races visible
Prefer disabling double-submit only while loading && sameVin, showing the VIN that owns the current generation next to spinner/error, and logging generation with metrics. Avoid a global timedOut boolean, clearing errors without bumping generation, or reusing one AbortController for the page lifetime.
Quick checks
import assert from "node:assert/strict";
let generation = 2;
const inFlight = { generation: 2 };
function isLive(gen: number) {
return gen === generation && inFlight.generation === gen;
}
assert.equal(isLive(2), true);
generation = 3;
assert.equal(isLive(2), false); // stale timeout must no-op
assert.equal(normalizeVin(" 1hgcm82633a004352 "), "1HGCM82633A004352");
assert.ok(VIN_RE.test("1HGCM82633A004352"));
In integration tests, fake fetch with deferred promises: resolve A after B's submit and assert the card shows B (or B's timeout), never A's late body or timeout string.
Takeaway
Timeouts and new submits share one UI lane. Give every attempt a generation, bind timer and AbortController to that generation, and refuse commits from superseded attempts. Your free VIN decode stays honest when a slow NHTSA call cannot paint -- or timeout-banner -- over the VIN the user already asked for next.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)