The exact problem: three async validators fire on one submit — a uniqueness check, an address lookup, a coupon check — and they resolve out of order, so the form renders the second one’s result over the third’s and the reader sees a verdict for a field they already fixed.

Context and Prerequisites

The sequencing strategies are compared in asynchronous validation strategies, and cancellation itself in cancelling stale async validation with AbortController. This page is about the case those do not cover: several different validators in flight at once, where the answer is a queue rather than an abort.

Core Pattern: A Per-Key Queue with a Generation Counter

type Key = string;   // usually the field name

interface Job<T> {
  readonly key: Key;
  readonly run: (signal: AbortSignal) => Promise<T>;
  readonly generation: number;      // which round of validation asked for this
}

export function createValidationQueue<T>(concurrency = 3) {
  const inflight = new Map<Key, AbortController>();
  const generations = new Map<Key, number>();
  let active = 0;
  const waiting: Array<() => void> = [];

  async function slot(): Promise<void> {
    if (active < concurrency) { active++; return; }
    // Bounded concurrency: a form with twenty async fields must not open twenty
    // connections, or the browser queues them anyway and the last ones time out.
    await new Promise<void>((r) => waiting.push(r));
    active++;
  }

  function release(): void {
    active--;
    waiting.shift()?.();
  }

  return async function enqueue(job: Job<T>): Promise<{ value: T; current: boolean }> {
    // Per-key supersede: a newer request for the SAME field cancels the older,
    // because only the latest answer for a field is ever wanted.
    inflight.get(job.key)?.abort();
    const controller = new AbortController();
    inflight.set(job.key, controller);
    generations.set(job.key, job.generation);

    await slot();
    try {
      const value = await job.run(controller.signal);
      // "current" is the caller's guard: a result from an older generation is
      // returned but flagged, so the caller can log it and not render it.
      const current = generations.get(job.key) === job.generation;
      return { value, current };
    } finally {
      release();
      if (inflight.get(job.key) === controller) inflight.delete(job.key);
    }
  };
}

Two mechanisms, doing different jobs. The per-key abort handles “the reader typed again in the same field” — only the latest matters, so the older one is cancelled. The generation counter handles “a whole new round of validation started” — a result from the previous round may still resolve, and it must be recognisable as stale rather than rendered.

Bounded concurrency across keys, supersede within a key With a concurrency limit of two, the email uniqueness check and the address lookup start immediately while the coupon check waits for a slot. When the reader edits the email field again, the new request for that key aborts the in-flight one rather than waiting behind it, because only the newest answer for a given field is ever wanted. The freed slot is then taken by the waiting coupon check. Each result carries the generation it belongs to, so a response from a superseded round can be recognised and discarded rather than rendered over a newer one. concurrency = 2 email uniqueness running · gen 4 address lookup running · gen 4 coupon check waiting for a slot reader edits email again same key → abort, do not queue slot freed coupon check starts Why both mechanisms are needed abort handles "newer request, same field" · generation handles "a whole new round started" a result can survive an abort race, and the generation is what catches it The four ways a job can end It resolves and its generation is current, which is the only case that renders. It resolves but its generation is stale, so it is logged and discarded. It is aborted because a newer request for the same field arrived, which is deliberate and must not count as a failure. Or it times out, which is neither a pass nor a fail: the field is left unjudged and the server decides at submit. Ending Render it? Count as a failure? resolved, current yes n/a resolved, stale no — log it no aborted no no — you caused it timed out as "could not check" no — not the reader’s fault Only the first row writes a verdict. The other three are the reason a boolean return type is not enough for an async validator.

Step-by-Step Walkthrough

  1. Key by field. Two validators for different fields are independent; two for the same field are not.

  2. Supersede within a key, queue across keys. Aborting a different field’s check to run this one is wrong; both answers are wanted.

  3. Bound the concurrency. Browsers cap connections per origin anyway; a bounded queue makes the ordering yours rather than the connection pool’s.

  4. Stamp a generation. Incremented whenever a fresh round starts — a submit, a step change, a reset.

  5. Return staleness rather than throwing it away. A stale result is useful for logging and for spotting a validator that is consistently too slow.

  6. Abort the whole queue on teardown. One signal that every job observes.

Failure Modes and Edge Cases

1. A validator that never resolves

A hung request holds its slot forever and starves everything behind it. Give every job a timeout, and treat the timeout as retryable-unknown rather than invalid.

2. Head-of-line blocking

A slow but unimportant check occupying the only slot delays a critical one. Either raise the concurrency or give jobs a priority and run high-priority ones first.

3. The reader submits while checks are in flight

Submit must either wait for the queue to drain or proceed and let the server decide. Waiting is usually right for a short queue; either way, do not submit while showing a “checking…” state and then also render its result afterwards.

4. Results that arrive after the field is gone

A conditional field removed while its check was running. Discard on the key’s absence rather than writing into a store entry that no longer exists.

5. Aborts counted as failures

An AbortError is deliberate. Counting it towards a retry budget makes a fast typist exhaust the budget without a single real failure.

What a generation bump means The generation is incremented whenever the set of answers being judged changes wholesale: a submit attempt, a wizard step change, a reset, or a draft restore. Every job dispatched afterwards carries the new number. A result arriving with an older number describes a payload that no longer exists, so it is discarded regardless of whether its abort was observed in time. What a generation bump means submit attempt a new judgement of the whole form step change a different set of answers is current reset or restore the values were replaced wholesale older results describe a payload that no longer exists Aborts race; the generation does not. It is the cheap second check at the point where the result would be used.

Verification Checklist

Common Pitfalls

  • Aborting across keys. Cancelling the address lookup to run the coupon check discards an answer that was wanted. Supersede within a key, queue across them.
  • No timeout. A hung request holds its slot indefinitely and starves everything behind it, which presents as validation that stopped working.
  • Counting aborts as failures. An AbortError is deliberate. Counting it towards a retry budget lets a fast typist exhaust it without a single real failure.
  • Writing a stale result. A response can resolve in the microtask before its abort is observed, so the generation check at the point of use is not redundant.
  • Submitting mid-queue without deciding. Either wait for the queue to drain or proceed and ignore what arrives afterwards — but not both, which renders a verdict the submit already moved past.

Related

Asynchronous Validation Strategies

Frequently Asked Questions

Why not just run every async validator in parallel?

Because the browser limits connections per origin, so beyond that limit they queue anyway — in an order you do not control and with no cancellation. A bounded queue gives you the ordering, lets you supersede within a field, and makes it possible to say which check is still outstanding. It also stops a form with twenty async fields from opening twenty connections and having the last few time out.

What is the generation counter for, if requests are already aborted?

Aborts race. A request can resolve in the microtask before its abort is observed, so a superseded result can still reach your handler. The generation stamp is a cheap second check at the point of use: if the result’s generation is not the current one, it is stale regardless of what the abort did. Belt and braces, for two lines.

Should submit wait for pending async validation?

For a short queue, yes — submitting while a uniqueness check is outstanding means the server does the same check a moment later and rejects, which is a wasted round trip and a worse message. For a long or slow queue, proceed and let the server decide, but then do not render the client’s result when it eventually arrives: the submit has already moved past it.