A free VIN decode that retries NHTSA DecodeVinValues after a blip can become a herd: tabs, workers, and cron jobs fail together, sleep the same delay, and stampede the public endpoint. Exponential backoff without jitter still aligns those clocks. This post covers decorrelated jitter for VIN decode retries -- spread wake-ups without inventing success or treating sparse 200s as failures.
Circuit breakers, idempotency keys, and Retry-After parsing are separate posts. Here the job is: when a retry is justified, wait a randomized interval so peers do not collide.
When jitter matters (and when it does not)
Jitter helps after transient upstream pain:
- Network timeouts and resets
- HTTP 429 / 502 / 503 / 504 you already classified as retryable
- Worker pools that re-queue the same VIN after a soft failure
Jitter does not fix:
- Local VIN validation failures (length, I/O/Q, check digit)
- HTTP 200 with empty feature fields (show "not provided," do not retry)
- Product impatience ("Make was blank, hit NHTSA again")
If the attempt should never have retried, spreading the wake-up time only wastes quota more politely.
Prefer decorrelated jitter over fixed or equal jitter
Fixed sleeps and equal jitter (base * 2^attempt ± 50%) still cluster when clients share the same attempt counter. Decorrelated jitter draws the next delay from a band based on the previous delay, so staggered clients drift apart instead of re-aligning.
export type JitterOpts = {
baseMs: number;
capMs: number;
random?: () => number; // [0, 1)
};
export function decorrelatedJitterMs(
previousDelayMs: number,
opts: JitterOpts,
): number {
const rnd = opts.random ?? Math.random;
const prev = Math.max(opts.baseMs, previousDelayMs);
const next = Math.min(
opts.capMs,
opts.baseMs + rnd() * (prev * 3 - opts.baseMs),
);
return Math.floor(next);
}
export async function sleep(ms: number): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
Start previousDelayMs at baseMs. After each failed attempt, compute the next sleep from the last one. Caps keep a hung tab from waiting forever; the random term keeps sibling tabs from sharing the same wake second.
Wire jitter into a decode retry loop
Keep classification outside the sleeper. The sleeper only spreads justified retries.
export type DecodeResult =
| { ok: true; body: unknown }
| { ok: false; retryable: boolean; status?: number };
export async function decodeWithJitteredRetries(
attempt: () => Promise<DecodeResult>,
opts: JitterOpts & { maxAttempts: number },
): Promise<DecodeResult> {
let delay = opts.baseMs;
let last: DecodeResult = { ok: false, retryable: false };
for (let i = 0; i < opts.maxAttempts; i++) {
last = await attempt();
if (last.ok) return last;
if (!last.retryable || i === opts.maxAttempts - 1) return last;
await sleep(delay);
delay = decorrelatedJitterMs(delay, opts);
}
return last;
}
Product rule: never mark a successful sparse decode as retryable. Empty feature fields or blank Make are finished decodes; retrying only burns quota.
Multi-tab and worker herds
Browsers open many tabs; workers fan out batches. First attempts can still correlate; jitter mainly helps on retries. Two habits shrink the first-hit herd:
- Single-flight per VIN in-process before any sleep.
-
Small startup stagger for batch jobs (
baseMs * random()before attempt 0) so cron fleets do not all fire on:00.
const inflight = new Map<string, Promise<DecodeResult>>();
export function singleFlightDecode(
vin: string,
run: () => Promise<DecodeResult>,
): Promise<DecodeResult> {
const existing = inflight.get(vin);
if (existing) return existing;
const p = run().finally(() => inflight.delete(vin));
inflight.set(vin, p);
return p;
}
export async function batchDecodeStaggered(
vins: string[],
decodeOne: (vin: string) => Promise<DecodeResult>,
opts: JitterOpts,
): Promise<DecodeResult[]> {
const out: DecodeResult[] = [];
for (const vin of vins) {
const stagger = Math.floor((opts.random ?? Math.random)() * opts.baseMs);
await sleep(stagger);
out.push(
await singleFlightDecode(vin, () =>
decodeWithJitteredRetries(() => decodeOne(vin), {
...opts,
maxAttempts: 3,
}),
),
);
}
return out;
}
Do not treat stagger as a substitute for rate limits. It only desynchronizes start times.
Forbidden product shortcuts
- Retrying every empty catalog field "just in case"
- Using the same fixed delay for all workers "for simpler logs"
- Claiming jitter "guarantees" NHTSA availability
- Hiding retry countdown as if the VIN were still decoding successfully
- Disabling jitter in production because staging had one client
Show users a clear retrying state. Never upgrade a failed decode into a fabricated Make/Model while waiting.
Quick checks
import assert from "node:assert/strict";
const seq = [0.1, 0.9, 0.4];
let i = 0;
const random = () => seq[i++ % seq.length]!;
const d1 = decorrelatedJitterMs(100, {
baseMs: 100,
capMs: 5_000,
random,
});
const d2 = decorrelatedJitterMs(d1, {
baseMs: 100,
capMs: 5_000,
random,
});
assert.ok(d1 >= 100 && d1 <= 5_000);
assert.ok(d2 >= 100 && d2 <= 5_000);
assert.notEqual(d1, d2);
let calls = 0;
const result = await decodeWithJitteredRetries(
async () => {
calls += 1;
if (calls < 3) return { ok: false, retryable: true, status: 503 };
return { ok: true, body: { Make: "HONDA" } };
},
{ baseMs: 1, capMs: 5, maxAttempts: 4, random: () => 0.5 },
);
assert.equal(result.ok, true);
assert.equal(calls, 3);
const sparse = await decodeWithJitteredRetries(
async () => ({ ok: true, body: { TractionControl: "" } }),
{ baseMs: 1, capMs: 5, maxAttempts: 3 },
);
assert.equal(sparse.ok, true);
Review rule: jitter modules must not reclassify sparse successes as retryable, and must not remove randomness "for determinism" in production paths.
Takeaway
Decorrelated jitter turns justified VIN decode retries into staggered wake-ups instead of synchronized herds against NHTSA. Pair it with honest retry classification and in-process single-flight; leave circuit breakers and idempotency for their own layers. Your free decode stays kinder to the public API when failed attempts drift apart -- and sparse successes never retry.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)