DEV Community

Vin Lookup
Vin Lookup

Posted on

Avoiding Race Conditions When VIN Decode Timeouts Overlap With New Submits

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:

  1. User submits VIN A. You start fetch with a 8s timeout timer.
  2. 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).
  3. A's timeout handler fires: sets error = "Decode timed out" and loading = false.
  4. 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.
  5. 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 };
}
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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)