DEV Community

Vin Lookup
Vin Lookup

Posted on

Ignoring Stale VIN Decode Responses After a Newer Request Wins the Race

A free VIN decode form often fires DecodeVinValues (or your thin proxy) on every paste or debounce tick. Users type fast: VIN A starts, then VIN B starts before A returns. If you apply every resolved promise, the card can briefly show B's Make/Model and then flip back to A's older payload -- or paint A's error under B's input.

This post is about a request-id / generation-counter stale-response guard: bump a monotonic id when you start a decode, stamp it on the in-flight promise, and commit UI state only when that id still matches the latest generation. AbortSignal, input-clear cancel UX, and timeout-overlap races are separate topics. Here the focus is out-of-order success and error payloads after a newer request already owns the screen.

The failure mode

Typical buggy sequence:

  1. User pastes VIN A; you fetch and keep the promise.
  2. Before A resolves, the user pastes VIN B; you start a second fetch (with or without aborting A).
  3. B returns first; you paint Make/Model for B. Good so far.
  4. A returns later; a careless .then still calls setResult(rowA).
  5. The input shows B, the card shows A's identity. Support tickets look like "decode lies."

Aborting A helps when cancel is honored. It fails when the proxy already finished, a shared promise lacks a generation stamp, or you only abort on empty input. A generation counter (or UUID request id) is the last line of defense: late bodies must be ignored when no longer current.

One generation per decode start

Bump generation (or mint a requestId) every time you start a decode for a normalized VIN. Store the stamp on the session. Every success and failure path checks session.generation === latestGeneration before mutating result, error, or loading.

export type DecodeRow = Record<string, string>;

export type DecodeUiState = {
  vin: string | null;
  row: DecodeRow | null;
  error: string | null;
  loading: boolean;
  generation: number;
};

export type DecodeSession = {
  generation: number;
  vinNormalized: string;
};

export function nextGeneration(state: DecodeUiState): number {
  return state.generation + 1;
}

export function isCurrent(
  session: DecodeSession,
  latestGeneration: number,
): boolean {
  return session.generation === latestGeneration;
}

export function applyDecodeSuccess(
  state: DecodeUiState,
  session: DecodeSession,
  row: DecodeRow,
): DecodeUiState {
  if (!isCurrent(session, state.generation)) {
    return state; // stale -- newer request already owns the UI
  }
  return {
    ...state,
    vin: session.vinNormalized,
    row,
    error: null,
    loading: false,
  };
}

export function applyDecodeError(
  state: DecodeUiState,
  session: DecodeSession,
  error: string,
): DecodeUiState {
  if (!isCurrent(session, state.generation)) {
    return state;
  }
  return {
    ...state,
    vin: session.vinNormalized,
    row: null,
    error,
    loading: false,
  };
}
Enter fullscreen mode Exit fullscreen mode

The guard is deliberately boring. Stale successes and stale errors both refuse to write. A late "404 from NHTSA" for A must not wipe B's good row. Treat "current generation" as the single source of truth for which VIN owns the card -- not whichever promise happens to settle last.

Wire it at the promise boundary

Start the session when you kick off work. Pass the same session into both branches. Do not close over a mutable let result without the check.

export async function runDecode(
  state: DecodeUiState,
  vinNormalized: string,
  fetchRow: (vin: string) => Promise<DecodeRow>,
): Promise<DecodeUiState> {
  const generation = nextGeneration(state);
  const session: DecodeSession = { generation, vinNormalized };
  let next: DecodeUiState = {
    ...state,
    generation,
    loading: true,
    error: null,
  };

  try {
    const row = await fetchRow(vinNormalized);
    next = applyDecodeSuccess(next, session, row);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Decode failed";
    next = applyDecodeError(next, session, message);
  }
  return next;
}
Enter fullscreen mode Exit fullscreen mode

If setState lands after another start bumped generation, applyDecodeSuccess no-ops. Product guarantee: newer request wins; older responses become no-ops.

What this is not

Do not conflate this guard with AbortSignal plumbing, input-clear cancel UX, timeout-only races, or SWR cache revalidation. Those are adjacent. You can combine abort + generation: abort reduces wasted work; generation prevents wrong paint when abort is incomplete.

Forbidden "fixes"

Product pressure often asks for:

  1. "Just always take the last response that arrives" without comparing ids (that is the bug)
  2. Showing a spinner until all in-flight promises settle (hangs the UI on abandoned A)
  3. Merging row A and row B fields because "some data is better than none"
  4. Skipping the guard for errors ("errors are harmless") -- they are not; they blank good cards
  5. Reusing one global promise for every VIN without stamping which VIN it belongs to

Refuse those. One stamp per start; commit only if current. The expensive bug is not a wasted network call -- it is a trustworthy-looking card that silently shows the wrong vehicle.

Quick checks

import assert from "node:assert/strict";

const base: DecodeUiState = {
  vin: null,
  row: null,
  error: null,
  loading: false,
  generation: 0,
};

const sessionA: DecodeSession = { generation: 1, vinNormalized: "A".repeat(17) };
const sessionB: DecodeSession = { generation: 2, vinNormalized: "B".repeat(17) };

let state: DecodeUiState = { ...base, generation: 2, loading: true };
state = applyDecodeSuccess(state, sessionB, { Make: "Beta" });
assert.equal(state.row?.Make, "Beta");

// Late A must not overwrite B
state = applyDecodeSuccess(state, sessionA, { Make: "Alpha" });
assert.equal(state.row?.Make, "Beta");

// Late A error must not wipe B
state = applyDecodeError(state, sessionA, "upstream timeout");
assert.equal(state.row?.Make, "Beta");
assert.equal(state.error, null);

assert.equal(isCurrent(sessionA, 2), false);
assert.equal(isCurrent(sessionB, 2), true);
Enter fullscreen mode Exit fullscreen mode

Review rule: decode commit paths must ignore mismatched generations. Never apply a resolved body solely because loading was once true.

Takeaway

Out-of-order VIN decode responses are normal on a chatty form. Stamp every start with a request id or generation counter, and ignore successes and errors that no longer match. Your free VIN UI stays trustworthy when the newest request owns the card -- and older payloads become silent no-ops instead of identity flip-flops.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)