Error-rate breakers trip when NHTSA is failing hard. A quieter failure mode is worse for forms: the upstream is merely slow. One hung DecodeVinValues call holds a shared client, saturates your concurrency budget, and every other VIN submit waits behind a spinner that never resolves. Retries with longer timeouts make the pile deeper.
This post is about latency-aware circuit breaking for free VIN decode: treat sustained slowness as a trip condition, fail fast for concurrent forms while open, and keep one slow call from stalling the whole UI surface. Classic consecutive-failure breakers are covered elsewhere; here the focus is slow upstream stalls every form.
Slow is not the same as down
| Signal | Typical user experience | Breaker role |
|---|---|---|
| Hard 5xx / network error | Immediate error card | Count as failure |
| Sparse 200 with Make/Model | Partial but usable card | Success (not a trip) |
| Hang past budget | Spinner forever; other forms blocked | Count as slow failure |
| Burst of 8s+ latencies | Queue of submits backs up | Open on rate of slow calls |
A form that shares one decode client (module singleton, server action pool, or browser fetch queue) is especially fragile. Without a breaker, the first hung request occupies the only in-flight slot; the second submit waits; the third looks "broken" even though validation is fine.
What should count as slow failure
Define an explicit per-call budget (for example 6–8s wall time). When the call exceeds the budget -- via AbortSignal.timeout, a racing Promise, or your HTTP client's deadline -- record a slow failure for the breaker, abort the in-flight work, and return a typed UPSTREAM_SLOW result.
Count toward the trip:
- Timeouts and deadline aborts
- Network errors that leave the request unresolved
- HTTP 502 / 503 / 504 after a long wait (still a failure; also slow)
Do not count:
- Local validation rejects (never called upstream)
- Fast 200s with empty ABS / Make gaps (honest sparse success)
- User-aborted navigations when you already cancelled cleanly
Mixing validation noise into slow-failure counts opens the circuit on bad paste days and blocks good VINs for no reason.
TypeScript sketch: latency trips + form gate
type BreakerState = "closed" | "open" | "half_open";
export type LatencyBreakerConfig = {
slowThresholdMs: number; // e.g. 7000
failureThreshold: number; // e.g. 3 consecutive slow/hard failures
cooldownMs: number; // e.g. 20_000
halfOpenMaxProbes: number; // e.g. 1
};
export class LatencyCircuitBreaker {
private state: BreakerState = "closed";
private consecutiveFailures = 0;
private openedAt = 0;
private halfOpenProbes = 0;
constructor(private readonly cfg: LatencyBreakerConfig) {}
canRequest(): boolean {
if (this.state === "closed") return true;
if (this.state === "open") {
if (Date.now() - this.openedAt >= this.cfg.cooldownMs) {
this.state = "half_open";
this.halfOpenProbes = 0;
return true;
}
return false;
}
return this.halfOpenProbes < this.cfg.halfOpenMaxProbes;
}
beforeRequest(): void {
if (this.state === "half_open") this.halfOpenProbes += 1;
}
recordSuccess(): void {
this.consecutiveFailures = 0;
this.state = "closed";
this.halfOpenProbes = 0;
}
recordFailure(): void {
this.consecutiveFailures += 1;
if (
this.state === "half_open" ||
this.consecutiveFailures >= this.cfg.failureThreshold
) {
this.state = "open";
this.openedAt = Date.now();
this.halfOpenProbes = 0;
}
}
}
export type DecodeResult<T> =
| { ok: true; data: T }
| { ok: false; reason: "circuit_open" | "upstream_slow" | "upstream" };
export async function decodeWithLatencyBreaker<T>(
breaker: LatencyCircuitBreaker,
call: (signal: AbortSignal) => Promise<T>,
cfg: { slowThresholdMs: number },
): Promise<DecodeResult<T>> {
if (!breaker.canRequest()) {
return { ok: false, reason: "circuit_open" };
}
breaker.beforeRequest();
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
cfg.slowThresholdMs,
);
const started = Date.now();
try {
const data = await call(controller.signal);
clearTimeout(timer);
breaker.recordSuccess();
return { ok: true, data };
} catch (err) {
clearTimeout(timer);
breaker.recordFailure();
const timedOut =
controller.signal.aborted &&
Date.now() - started >= cfg.slowThresholdMs - 50;
return {
ok: false,
reason: timedOut ? "upstream_slow" : "upstream",
};
}
}
Gate every form submit on canRequest() before disabling inputs or starting a spinner. When the circuit is open, return immediately with circuit_open so sibling forms stay interactive.
UX that does not stall the form
Prefer:
- Instant "decode temporarily unavailable -- upstream is slow" when open
- Disable only the submit that would call NHTSA; keep paste/clear/help controls alive
- Optional stale last-good decode for that VIN, labeled "cached earlier; live refresh paused"
Avoid:
- A global page-level spinner held by one hung fetch
- Mapping
circuit_openorupstream_slowto "invalid VIN" - Silent retries that re-queue the same hung call behind the open circuit
Keep reason codes separate (CIRCUIT_OPEN vs UPSTREAM_SLOW vs VIN_INVALID) so support and status copy stay accurate.
Quick checks
import assert from "node:assert/strict";
const breaker = new LatencyCircuitBreaker({
slowThresholdMs: 50,
failureThreshold: 2,
cooldownMs: 60_000,
halfOpenMaxProbes: 1,
});
async function hang(_signal: AbortSignal): Promise<never> {
await new Promise(() => {});
throw new Error("unreachable");
}
const a = await decodeWithLatencyBreaker(breaker, hang, {
slowThresholdMs: 50,
});
assert.equal(a.ok, false);
if (!a.ok) assert.equal(a.reason, "upstream_slow");
const b = await decodeWithLatencyBreaker(breaker, hang, {
slowThresholdMs: 50,
});
assert.equal(b.ok, false);
assert.equal(breaker.canRequest(), false);
const blocked = await decodeWithLatencyBreaker(breaker, hang, {
slowThresholdMs: 50,
});
assert.deepEqual(blocked, { ok: false, reason: "circuit_open" });
Review rule: form submit handlers must short-circuit on open before awaiting upstream. Never let one slow call own the only concurrency slot without a budget.
Takeaway
One slow NHTSA call should not freeze every VIN form. Budget each decode, count timeouts as failures, open the circuit before the queue piles up, and fail fast with an honest "temporarily unavailable" state. Layer latency breakers with validation and single-flight, and free VIN decode stays responsive when the public API merely crawls instead of crashing.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)