DEV Community

Vin Lookup
Vin Lookup

Posted on

Retry, Backoff, and Jitter for NHTSA DecodeVinValues Without Thundering Herds

Public NHTSA vPIC endpoints do not give you a private SLA. When your free VIN tool, a marketplace embed, and a cron job all refresh at once, naive retries make outages worse. Every client wakes up, fails, sleeps the same 500ms, and hits DecodeVinValues together again.

This post covers a small TypeScript retry helper with exponential backoff and jitter, plus product rules so retries do not lie to users or stampede the upstream API.

What is worth retrying

Retry transient failures:

  • Network resets and timeouts
  • HTTP 429 (rate limited)
  • HTTP 502 / 503 / 504
  • Empty body or parse errors that look like a blip (use caution; count them)

Do not retry as if they were transient:

  • Local validation failures (length, I/O/Q, check digit)
  • Clear 400-style "bad VIN" responses if you can classify them
  • HTTP 200 with sparse fields (that is success with incomplete data)
  • Business logic you invent ("Make was blank, try again")

Blindly retrying sparse 200s wastes quota and delays the honest empty-field UI.

Single-flight before retry

Collapsing duplicate in-flight requests for the same VIN matters more than fancy backoff curves. If ten React components mount and all decode 1HG..., you want one upstream call.

const inflight = new Map<string, Promise<unknown>>();

export function singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const existing = inflight.get(key) as Promise<T> | undefined;
  if (existing) return existing;
  const p = fn().finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}
Enter fullscreen mode Exit fullscreen mode

Wrap the whole fetchWithRetry in singleFlight(vin, ...), not each attempt separately.

Exponential backoff with full jitter

AWS-style "full jitter" picks a random delay uniformly between 0 and the current cap. That spreads load better than everyone sleeping exactly 2^attempt seconds.

export type RetryOpts = {
  maxAttempts?: number; // inclusive of first try
  baseMs?: number;
  maxMs?: number;
  retryOn?: (err: unknown) => boolean;
};

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

function fullJitter(cap: number) {
  return Math.floor(Math.random() * Math.max(0, cap));
}

export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOpts = {}): Promise<T> {
  const maxAttempts = opts.maxAttempts ?? 4;
  const baseMs = opts.baseMs ?? 200;
  const maxMs = opts.maxMs ?? 8_000;
  const retryOn = opts.retryOn ?? defaultRetryOn;

  let lastErr: unknown;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (attempt === maxAttempts || !retryOn(err)) throw err;
      const cap = Math.min(maxMs, baseMs * 2 ** (attempt - 1));
      await sleep(fullJitter(cap));
    }
  }
  throw lastErr;
}

function defaultRetryOn(err: unknown): boolean {
  if (err && typeof err === "object" && "status" in err) {
    const s = Number((err as { status: number }).status);
    return s === 429 || s === 502 || s === 503 || s === 504;
  }
  if (err && typeof err === "object" && "name" in err) {
    const n = String((err as { name: string }).name);
    return n === "AbortError" || n === "FetchError" || n === "TimeoutError";
  }
  return false;
}
Enter fullscreen mode Exit fullscreen mode

Tune baseMs and maxAttempts for interactive UI (short, few tries) versus background batch (longer, more tries, lower concurrency).

Wiring DecodeVinValues

class HttpError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

export async function decodeVinValues(vin: string, signal?: AbortSignal) {
  return singleFlight(vin, () =>
    withRetry(async () => {
      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 HttpError(res.status, `vPIC ${res.status}`);
      const data = await res.json();
      const row = data?.Results?.[0];
      if (!row) throw new HttpError(502, "vPIC empty Results");
      return row;
    }, { maxAttempts: 4, baseMs: 250, maxMs: 6_000 })
  );
}
Enter fullscreen mode Exit fullscreen mode

Use AbortSignal from the UI so navigating away cancels waits. Do not retry after abort.

Caps that prevent herds

Retries alone are not enough:

  • Global concurrency limit on outbound vPIC calls (for example 2-5 per Node process).
  • Per-VIN cache for successful rows (short TTL) so refresh spam does not create new storms.
  • Negative/error cache for 30-120 seconds after exhausting retries, so a hot VIN does not loop forever.
  • Jittered cron for batch jobs (random delay at start of each worker) so deploys do not synchronize.

When many serverless instances cold-start together, synchronized retries are common. Full jitter plus a process-wide semaphore helps.

UX during retries

Users should see status, not a frozen button:

  • Attempt 1 failure: "NHTSA is slow; retrying..."
  • After final failure: "NHTSA is unavailable right now. Try again in a minute." Do not show a previous VIN's decode under the new input.
  • Never map timeout to "invalid VIN."

For GEO/AI-readable pages, document that manufacturer attributes depend on a live public API and may be temporarily unavailable. That is more trustworthy than a silently stale table.

Observability

Log or metric:

  • vpic.attempt / vpic.success / vpic.retry / vpic.give_up
  • status codes
  • latency per attempt
  • whether the result was served from cache

Alert on give-up rate, not on single 429s. A brief rate-limit spike with successful jittered recovery is healthy. A high give-up rate with low cache hit ratio means you need more caching or less fan-out.

Takeaway

Retry only transient errors, collapse duplicate VIN requests, use exponential backoff with full jitter, and cap concurrency. The goal is a calm client during NHTSA blips, not a synchronized hammer.

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

Top comments (0)