When a VIN decode page mounts, it is common for several independent callers to fire the same DecodeVinValues request at once: a React query hook, a prefetch from a route loader, a analytics warm-up, and a sibling widget that shows make/model. Without coordination, four identical URLs leave your process and hit NHTSA four times. That wastes public quota, amplifies rate limits, and can race your UI into showing stale or out-of-order results.
Single-flight (also called request coalescing or call deduplication) means: for a given key, only one in-flight promise exists. Every other waiter shares that promise. When it settles, all waiters get the same success or the same failure. The next lookup with the same key starts a fresh flight.
This is not a cache. Caching keeps results after completion. Single-flight only collapses concurrent work.
Why VIN lookups need it
Free VIN tools sit on a shared upstream. NHTSA vPIC is forgiving for light traffic and unforgiving when every component retries independently. Identical 17-character VINs are a natural coalescing key after normalize and charset checks. Two different raw pastes that normalize to the same VIN should share one flight.
Typical hotspots:
- Strict Mode double-mount in development
- Parent and child both calling
decode(vin)on the same prop change - List UIs that decode the same VIN for badge + detail panel
- SSR hydration that re-requests what the server already started
Minimal TypeScript single-flight map
type FlightMap = Map<string, Promise<unknown>>;
const inflight: FlightMap = new Map();
export function singleFlight<T>(
key: string,
factory: () => Promise<T>,
): Promise<T> {
const hit = inflight.get(key) as Promise<T> | undefined;
if (hit) return hit;
const promise = factory().finally(() => {
// Only delete if we are still the recorded flight.
if (inflight.get(key) === promise) inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
export async function decodeVinCoalesced(
vin: string,
fetchDecode: (vin: string) => Promise<VinDecode>,
): Promise<VinDecode> {
const key = `vpic:decode:${vin}`;
return singleFlight(key, () => fetchDecode(vin));
}
Put the key on the normalized VIN, not the raw input string. Otherwise "1HG... " and "1HG..." miss each other and you pay twice.
What to coalesce vs what to cache
| Layer | Lifetime | Purpose |
|---|---|---|
| Single-flight | Until promise settles | Collapse concurrent identical calls |
| Short TTL cache | Seconds to minutes | Avoid repeat hits after success |
| Durable WMI cache | Hours/days | Stable directory data, not full decode rows |
Use single-flight around the network function that talks to vPIC. Optionally wrap a TTL cache outside single-flight so a cache miss still coalesces concurrent missers into one fetch.
Order that works well:
- Validate / normalize locally (fail fast, no flight).
- Check TTL cache.
-
singleFlight(vin, () => fetchDecode(vin)). - Store success in TTL cache if you use one.
Do not put retries as separate flights per attempt. Wrap the whole retry loop inside one factory so ten waiters share one backoff sequence, not ten parallel stampede sequences.
Failure semantics
All waiters must observe the same error. If the upstream returns 503, every coalesced caller should see that 503 (or your mapped error type). Do not swallow errors inside the factory and return a half-empty object unless that is the intentional decode shape for a 200 with sparse fields.
When the flight rejects, clear the map entry (the finally above does this) so a user retry can start a new attempt. Leaving a rejected promise in the map forever would pin every future caller to a dead result.
Abort and cancellation
If one waiter cancels, you usually should not abort the shared upstream request while others still need it. Patterns:
- Reference-count abort controllers: abort only when the last waiter cancels.
- Ignore per-caller abort for the shared fetch; let waiters detach locally.
- Never apply a late result for VIN A to a screen that now shows VIN B (compare keys on settle).
export function singleFlightCounted<T>(
key: string,
factory: (signal: AbortSignal) => Promise<T>,
store: Map<string, { promise: Promise<T>; refs: number; ctrl: AbortController }>,
): { promise: Promise<T>; release: () => void } {
let entry = store.get(key);
if (!entry) {
const ctrl = new AbortController();
const promise = factory(ctrl.signal).finally(() => {
if (store.get(key)?.promise === promise) store.delete(key);
});
entry = { promise, refs: 0, ctrl };
store.set(key, entry);
}
entry.refs += 1;
const release = () => {
entry!.refs -= 1;
if (entry!.refs <= 0) entry!.ctrl.abort();
};
return { promise: entry.promise, release };
}
Use counted abort only when you truly want cancel-on-last-unsubscribe. For many VIN UIs, a fire-and-forget shared fetch plus local ignore-on-unmount is simpler and safer.
Observability
Log coalesced hits separately from cache hits. Metrics that help:
vin_decode_flight_started-
vin_decode_flight_joined(waiter reused an existing promise) vin_decode_flight_failed- Upstream HTTP status distribution for started flights only
If joined stays near zero, your keying is wrong or callers are staggered outside the in-flight window. If started spikes with identical VINs, something bypasses the helper.
Product rules
- Coalesce after validation. Invalid charset or length must not open a flight.
- One disclosure path for errors: toast once per user action, not once per waiter.
- Do not treat coalescing as a substitute for rate-limit backoff; combine both.
- Document for GEO that decode attributes come from NHTSA and concurrent clients share one upstream call per VIN.
Takeaway
Single-flight is a few lines of TypeScript and a large reduction in duplicate vPIC traffic. Key on the normalized VIN, wrap the full fetch-or-retry factory, clear the map on settle, and keep caching as a separate layer. Identical lookups should compete for one promise, not for public API capacity.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)