DEV Community

Vin Lookup
Vin Lookup

Posted on

Propagating Deadlines from VIN Form Submit to NHTSA Proxy Without Orphaned Work

A free VIN form that abandons a slow decode still often leaves a DecodeVinValues call running behind the proxy. The user navigated away, a client timeout fired, or a newer submit superseded the old one -- yet the edge kept waiting on NHTSA and burned quota on a response nobody will display. Per-request budgets (covered elsewhere) decide how long a serverless handler may wait. This post is about propagating that deadline as an AbortSignal from form submit through your BFF so orphaned upstream work is cancelled early.

The goal is narrow: one submit, one deadline, one cancellable fetch chain. Do not confuse this with hedged dual-fire, idempotency keys, or jittered retries. Here the job is: when the UI no longer wants the result, stop waiting on the proxy path that would call NHTSA.

Where orphaned work comes from

Orphans appear when deadlines live only at one layer:

  • Browser AbortController aborts the BFF fetch, but the BFF ignores req.signal and keeps calling DecodeVinValues
  • BFF has a hard setTimeout that returns 504 to the client while the outbound NHTSA promise is still pending
  • A second form submit starts a new decode without aborting the first in-flight attempt
  • Serverless budgets shrink remaining time, but nobody wires that remaining budget into fetch(..., { signal })

Each case spends free-quota units on work the card will never show. Propagation means the same abort reason travels from submit handler to outbound proxy call.

Mint a deadline at submit, pass the signal

Create the controller when the user submits (or when the form auto-decodes once). Attach a wall-clock deadline, pass signal to your BFF, and abort on unmount, navigation, or superseding submit.

export type DecodeSubmitOpts = {
  vin: string;
  deadlineMs: number; // e.g. 8_000 from UX budget
  fetchProxy: (vin: string, signal: AbortSignal) => Promise<unknown>;
};

export function submitDecodeWithDeadline(
  opts: DecodeSubmitOpts,
): { promise: Promise<unknown>; abort: (reason?: unknown) => void } {
  const ctrl = new AbortController();
  const timer = setTimeout(() => {
    ctrl.abort(new Error("CLIENT_DEADLINE"));
  }, opts.deadlineMs);

  const promise = opts
    .fetchProxy(opts.vin, ctrl.signal)
    .finally(() => clearTimeout(timer));

  return {
    promise,
    abort: (reason) => ctrl.abort(reason ?? new Error("CLIENT_CANCEL")),
  };
}

export function supersedePrevious(
  previous: { abort: (reason?: unknown) => void } | null,
): void {
  previous?.abort(new Error("SUPERSEDED"));
}
Enter fullscreen mode Exit fullscreen mode

On a second tap, abort the previous handle before minting a new one. Do not let two proxy calls race "for completeness" when only the latest submit should paint the card.

Proxy: forward the inbound signal to NHTSA

Your BFF must treat the inbound request signal as authoritative. Compose it with any remaining serverless budget so either expiry aborts the outbound DecodeVinValues fetch.

export function mergeAbortSignals(
  ...signals: AbortSignal[]
): AbortSignal {
  const ctrl = new AbortController();
  for (const s of signals) {
    if (s.aborted) {
      ctrl.abort(s.reason);
      return ctrl.signal;
    }
    s.addEventListener(
      "abort",
      () => ctrl.abort(s.reason),
      { once: true },
    );
  }
  return ctrl.signal;
}

export async function proxyDecodeVin(args: {
  vin: string;
  inbound: AbortSignal;
  budgetSignal: AbortSignal;
  decodeVinValues: (vin: string, signal: AbortSignal) => Promise<unknown>;
}): Promise<unknown> {
  const signal = mergeAbortSignals(args.inbound, args.budgetSignal);
  if (signal.aborted) {
    throw signal.reason ?? new Error("ABORTED_BEFORE_UPSTREAM");
  }
  return args.decodeVinValues(args.vin, signal);
}
Enter fullscreen mode Exit fullscreen mode

If the client already aborted, do not open a new upstream socket "to populate cache anyway." Cache warming is a separate, explicitly scheduled job -- not a side effect of a cancelled user decode.

Budgets and propagation compose: the serverless remaining-time check decides whether a retry is allowed; the AbortSignal decides whether work already in flight should stop. Propagating a deadline without aborting outbound fetch only returns early to the browser while NHTSA keeps running -- the orphan problem this post targets.

Forbidden upgrades

Product pressure often asks for:

  1. Ignoring client aborts so the proxy "finishes for analytics"
  2. Returning a synthetic 200 after abort by merging a partial body
  3. Letting superseded submits complete in the background without metrics
  4. Treating deadline abort as a retry trigger (that belongs in backoff modules)
  5. Counting only displayed responses against quota while aborted upstreams stay invisible

Refuse those. Log decode_aborted_client, decode_aborted_budget, and decode_superseded. Count issued DecodeVinValues calls, including ones cancelled after the request left your edge.

Ops copy that stays honest

Prefer:

  • Metrics that separate user cancel, deadline, and supersede
  • Proxy logs that record whether outbound fetch received an AbortSignal
  • A kill switch that fails closed (reject new submits) when abort rate spikes with dual open upstreams

Avoid:

  • Silent background completion after the UI showed a timeout
  • Alerting only on HTTP 5xx while orphans inflate NHTSA traffic
  • Documenting "we always finish the decode" as a reliability feature when the card is gone

Quick checks

import assert from "node:assert/strict";

const calls: string[] = [];
const { promise, abort } = submitDecodeWithDeadline({
  vin: "1HGCM82633A004352",
  deadlineMs: 50,
  fetchProxy: async (_vin, signal) => {
    calls.push("start");
    await new Promise<void>((resolve, reject) => {
      const t = setTimeout(() => resolve(), 500);
      signal.addEventListener("abort", () => {
        clearTimeout(t);
        reject(signal.reason);
      });
    });
    calls.push("done");
    return {};
  },
});

await assert.rejects(promise);
assert.ok(!calls.includes("done"));

const inbound = AbortSignal.abort(new Error("CLIENT_CANCEL"));
await assert.rejects(
  proxyDecodeVin({
    vin: "1HGCM82633A004352",
    inbound,
    budgetSignal: new AbortController().signal,
    decodeVinValues: async () => {
      calls.push("upstream");
      return {};
    },
  }),
);
assert.ok(!calls.includes("upstream"));
Enter fullscreen mode Exit fullscreen mode

Review rule: proxy decode modules must pass a merged AbortSignal into outbound NHTSA fetch and must not start upstream work when the inbound signal is already aborted.

Takeaway

Deadline propagation turns a form-level cancel into a cancellable NHTSA proxy call. Without it, client timeouts and superseded submits leave orphaned DecodeVinValues work that burns free quota for a card nobody will see. Mint the AbortController at submit, forward the signal through the BFF, merge it with any serverless budget, and refuse background "finish anyway" completions. Keep this path separate from hedges and idempotency keys so abandoned VIN lookups stop spending upstream time as soon as the UI stops caring.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)