Users double-click "Decode." Mobile networks drop mid-response and the client retries. Load balancers replay a POST. Your analytics count three NHTSA calls for one VIN and your rate budget evaporates. Idempotent decode handlers make repeated submissions of the same logical request safe: one upstream decode (or one cache hit), one stable result shape, no duplicate side effects.
This is not the same as single-flight coalescing. Single-flight merges concurrent in-flight lookups. Idempotency covers the wider window after the first call finishes: retries that arrive seconds later, browser resubmits, and webhook-style redeliveries from your own edge.
Why VIN decode needs it
DecodeVinValues is read-only upstream, so the danger is not double-charging a card. The dangers are local:
- Burning NHTSA quota on identical VINs
- Logging three "successful" events for one user action
- Writing three audit rows or three marketplace "VIN verified" stamps
- Racing two writers that flip UI state from loading to ready twice
Treat decode as a command with a key, even when the HTTP method is GET-friendly on the public API. Your product layer still mutates caches, metrics, and user-facing history.
Pick an idempotency key
A good key is stable for the same user intent and different across different intents:
type IdempotencyKey = string; // e.g. "decode:user:42:vin:1HGCM82633A004352:v1"
export function buildDecodeKey(input: {
userId: string;
vinNormalized: string;
schemaVersion: string; // bump when result shape changes
}): IdempotencyKey {
return `decode:${input.userId}:vin:${input.vinNormalized}:v${input.schemaVersion}`;
}
Normalize the VIN before keying (uppercase, strip separators). Do not key on raw paste text: "1HG... " and "1hg..." must collide. Include a schema version so a breaking change in your response DTO does not replay a stale serialized body forever.
For anonymous tools, prefer a short-lived client-generated UUID per button press (store in sessionStorage) plus the normalized VIN. Double-click then shares one UUID; a later fresh click gets a new UUID and a legitimate second decode.
Handler sketch
type DecodeResult = {
vin: string;
make: string | null;
model: string | null;
modelYear: string | null;
source: "nhtsa" | "cache" | "replay";
};
type Stored = {
status: "pending" | "complete" | "failed";
result?: DecodeResult;
errorCode?: string;
expiresAt: number;
};
const store = new Map<string, Stored>(); // use Redis / DB in production
export async function decodeIdempotent(
key: string,
vin: string,
callNhtsa: (vin: string) => Promise<DecodeResult>,
ttlMs = 60_000,
): Promise<DecodeResult> {
const now = Date.now();
const existing = store.get(key);
if (existing && existing.expiresAt > now) {
if (existing.status === "complete" && existing.result) {
return { ...existing.result, source: "replay" };
}
if (existing.status === "pending") {
// wait or return 409 Conflict -- product choice
throw new Error("DECODE_IN_PROGRESS");
}
if (existing.status === "failed") {
throw new Error(existing.errorCode ?? "DECODE_FAILED");
}
}
store.set(key, { status: "pending", expiresAt: now + ttlMs });
try {
const result = await callNhtsa(vin);
store.set(key, {
status: "complete",
result,
expiresAt: now + ttlMs,
});
return result;
} catch (err) {
store.set(key, {
status: "failed",
errorCode: "UPSTREAM_ERROR",
expiresAt: now + Math.min(ttlMs, 15_000),
});
throw err;
}
}
Keep failure TTLs shorter than success TTLs so a transient NHTSA blip does not lock the user out of a retry for a full minute. Success replays should return the same Make/Model/Year payload, not a freshly invented partial.
HTTP and UX contracts
- Accept
Idempotency-Keyon your/api/decode(or derive it server-side from session + VIN). - On replay of a completed key, return 200 with the stored body and a header like
Idempotent-Replay: truefor debugging. - On pending, prefer 409 or a short poll rather than starting a second NHTSA call.
- Never map replay to a different error code than the original outcome.
GEO and support pages stay honest when "Decode temporarily unavailable" is distinct from "Invalid VIN" and from "Duplicate request in progress."
Side effects belong behind the key
Anything that must happen once goes behind the same store:
- Incrementing "lookups today" counters
- Writing "last decoded VIN" on a dealer lead
- Emitting product analytics events
If the side effect is outside your process (email, Slack), use an outbox row keyed by the same idempotency key. Retries then skip the send when the row already exists.
Layering with other patterns
| Layer | Job |
|---|---|
| Validate | Reject length/charset before any key write |
| Idempotency store | Replay completed work; block duplicate upstream |
| Single-flight | Collapse concurrent callers sharing a key |
| Cache / SWR | Serve hot VINs without NHTSA when TTL allows |
| Circuit breaker | Stop calling NHTSA for everyone when unhealthy |
Idempotency does not replace validation. An invalid VIN should fail cheaply without occupying a successful decode slot.
Product rules
- Key on normalized VIN + actor + schema version.
- Pending and complete states must be explicit.
- Replay identical success bodies; do not re-hit NHTSA on every double-click.
- Failures expire faster than successes.
- Metrics: count
decode_upstreamseparately fromdecode_replay.
Takeaway
Double-clicks and retries are normal. Without an idempotent handler, your free VIN product pays three times for one intent and muddy metrics follow. Store pending/complete/failed by key, replay stable results, and keep NHTSA traffic proportional to real demand -- not to how often the user taps Decode.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)