A free VIN decode edge that lets every paste, refresh, and parallel tab fire DecodeVinValues immediately will burn through shared NHTSA courtesy quotas in a short burst -- even when each request is valid. Debounce collapses mid-typing keystrokes before a call starts. Load-shed drops optional enrichment when latency spikes. Retry jitter scatters wake-ups after a transient failure. This post is narrower: token-bucket the admitted decode rate so sustained and burst traffic stay inside a budget you chose.
The goal is honest rate control: admit up to a steady refill rate, allow a capped burst when tokens remain, and refuse excess without inventing cached catalog rows to fake a successful decode.
Why a token bucket (not only debounce)
Debounce answers "wait until typing pauses." It does not limit how many complete 17-character VINs a power user or multi-tab session can submit per second after each pause. Load-shed skips enrichment when p95 is high but still allows primary decodes to stampede. A token bucket asks: how many DecodeVinValues calls may leave this process in a window?
Typical pressure: paste-heavy tabs, warm serverless instances that each assume the full public quota, and cron warmers sharing one upstream identity. Without a local budget those paths compete until NHTSA returns 429.
Model: capacity, refill, tryTake
A classic bucket holds up to capacity tokens and refills at refillPerSecond. Each admitted decode costs one token. If the bucket is empty, refuse or wait -- do not silently invent a card.
export type TokenBucket = { capacity: number; tokens: number; refillPerSecond: number; updatedAtMs: number; }; export function createTokenBucket(opts: { capacity: number; refillPerSecond: number; nowMs?: number; }): TokenBucket { const now = opts.nowMs ?? Date.now(); return { capacity: opts.capacity, tokens: opts.capacity, refillPerSecond: opts.refillPerSecond, updatedAtMs: now, }; } export function refill(bucket: TokenBucket, nowMs: number): void { const elapsedSec = Math.max(0, (nowMs - bucket.updatedAtMs) / 1000); bucket.tokens = Math.min( bucket.capacity, bucket.tokens + elapsedSec * bucket.refillPerSecond, ); bucket.updatedAtMs = nowMs; } export type TakeResult = | { ok: true; remaining: number } | { ok: false; retryAfterMs: number; remaining: number }; export function tryTake( bucket: TokenBucket, nowMs: number, cost = 1, ): TakeResult { refill(bucket, nowMs); if (bucket.tokens >= cost) { bucket.tokens -= cost; return { ok: true, remaining: bucket.tokens }; } const need = cost - bucket.tokens; const retryAfterMs = Math.ceil( (need / bucket.refillPerSecond) * 1000, ); return { ok: false, retryAfterMs, remaining: bucket.tokens }; } capacity is the burst you tolerate. refillPerSecond is the sustained rate you will send toward NHTSA from this client identity. Tune both from observed courtesy limits and your multi-tenant share -- not from assuming unlimited public quota.
Wire the bucket before fetch
export type DecodeAdmit = | { admitted: true; remaining: number } | { admitted: false; retryAfterMs: number; reason: "token_bucket_empty"; }; export function admitDecode( bucket: TokenBucket, nowMs: number, ): DecodeAdmit { const take = tryTake(bucket, nowMs, 1); if (take.ok) { return { admitted: true, remaining: take.remaining }; } return { admitted: false, retryAfterMs: take.retryAfterMs, reason: "token_bucket_empty", }; } export async function decodeWithTokenBucket(opts: { vin: string; bucket: TokenBucket; nowMs: number; fetchDecode: (vin: string) => Promise<Record<string, string>>; }): Promise< | { status: "ok"; fields: Record<string, string>; remaining: number } | { status: "rate_limited"; retryAfterMs: number; fields: null; } > { const admit = admitDecode(opts.bucket, opts.nowMs); if (!admit.admitted) { return { status: "rate_limited", retryAfterMs: admit.retryAfterMs, fields: null, }; } const fields = await opts.fetchDecode(opts.vin); return { status: "ok", fields, remaining: admit.remaining }; } When rate_limited, return a clear retry hint. Do not refill the UI from a sibling VIN cache and pretend the limited call succeeded.
Forbidden upgrades
Product pressure often asks for:
- Serving Make/Model folklore when the bucket is empty so the form "still works"
- Sharing one oversized bucket across all tenants so one scraper starves everyone
- Setting capacity to Infinity "until we see 429" (you will)
- Collapsing debounce, load-shed, and token-bucket into one vague "slow down" flag
Refuse those. Debounce delays start-of-typing. Load-shed drops enrichment. Token-bucket limits admitted primary calls. Keep the tools separate.
export function rateLimitLines(result: { status: string; retryAfterMs?: number; }): string[] { if (result.status === "rate_limited") { return [ "Decode rate limited -- local token bucket empty", `Retry after roughly ${result.retryAfterMs ?? 0} ms`, ]; } return ["Decode admitted under token-bucket budget"]; } export function assertNoTokenBucketInvent(moduleSource: string): void { const banned = [ /typical for this model/i, /served from sibling vin/i, /quota.?unlimited/i, /fake.?success.?while.?limited/i, ]; for (const re of banned) { if (re.test(moduleSource)) { throw new Error( `Token-bucket module must not invent decode results: ${re}`, ); } } } UI copy that stays honest
Prefer:
- "Too many decodes right now -- try again in a moment"
- "Rate limited locally to protect NHTSA quota"
Avoid:
- Grey cards filled with "usual for this model year" while limited
- Silent success toasts when
tryTakefailed
Quick checks
import assert from "node:assert/strict"; const b = createTokenBucket({ capacity: 2, refillPerSecond: 1, nowMs: 0, }); assert.equal(tryTake(b, 0).ok, true); assert.equal(tryTake(b, 0).ok, true); const denied = tryTake(b, 0); assert.equal(denied.ok, false); if (!denied.ok) { assert.ok(denied.retryAfterMs > 0); } refill(b, 1000); assert.equal(tryTake(b, 1000).ok, true); const limited = await decodeWithTokenBucket({ vin: "1HGCM82633A004352", bucket: createTokenBucket({ capacity: 0, refillPerSecond: 1, nowMs: 0 }), nowMs: 0, fetchDecode: async () => ({ Make: "HONDA" }), }); assert.equal(limited.status, "rate_limited"); assert.equal(limited.fields, null); assert.ok( rateLimitLines(limited).some((l) => /token bucket empty/i.test(l)), ); assert.ok( !rateLimitLines(limited).some((l) => /typical for this model|sibling vin/i.test(l), ), ); Review rule: token-bucket modules must not invent catalog fields on refusal, and must stay distinct from debounce and load-shed.
Takeaway
Token-bucket VIN decode clients so bursts cannot exhaust the NHTSA-facing budget you share. Cap capacity, refill steadily, refuse cleanly when empty, and keep debounce, load-shed, and retry jitter as separate tools. Your free VIN API stays kind to upstream when admitted calls match a real budget -- without inventing decode results over a dry bucket.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
