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.
Step-by-Step Walkthrough
-
Key by field. Two validators for different fields are independent; two for the same field are not.
-
Supersede within a key, queue across keys. Aborting a different field’s check to run this one is wrong; both answers are wanted.
-
Bound the concurrency. Browsers cap connections per origin anyway; a bounded queue makes the ordering yours rather than the connection pool’s.
-
Stamp a generation. Incremented whenever a fresh round starts — a submit, a step change, a reset.
-
Return staleness rather than throwing it away. A stale result is useful for logging and for spotting a validator that is consistently too slow.
-
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.
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
AbortErroris 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 — the four sequencing strategies
- Cancelling Stale Async Validation with AbortController — the single-field case
- Implementing Async Email Availability Checks — a validator this queue would run
← 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.