Yo folks, I’m wiring up a signup form and I’m trying to validate a username as the user types, but I keep hitting a failure mode where slower responses overwrite newer ones and the field toggles between valid/invalid.
const input = document.querySelector('#username');
const status = document.querySelector('#status');
let controller;
input.addEventListener('input', async (e) => {
const value = e.target.value.trim();
if (!value) return;
controller?.abort();
controller = new AbortController();
status.textContent = 'checking...';
const res = await fetch(`/api/username?u=${encodeURIComponent(value)}`, {
signal: controller.signal
});
const { available } = await res.json();
input.setCustomValidity(available ? '' : 'Taken');
status.textContent = available ? 'ok' : 'taken';
});
What’s the cleanest way to make this reliable so aborted/older requests can’t stomp newer validation, without making the UX laggy or the tests flaky?
1 Like
Catch the abort and ignore it, and keep the little seq guard so only the newest request is allowed to update the UI (abort timing can be weird in tests and some environments).
let controller;
let seq = 0;
input.addEventListener('input', async (e) => {
const value = e.target.value.trim();
if (!value) {
controller?.abort();
input.setCustomValidity('');
status.textContent = '';
return;
}
const mySeq = ++seq;
controller?.abort();
controller = new AbortController();
status.textContent = 'checking...';
try {
const res = await fetch(`/api/username?u=${encodeURIComponent(value)}`, {
signal: controller.signal
});
const { available } = await res.json();
if (mySeq !== seq) return;
input.setCustomValidity(available ? '' : 'Taken');
status.textContent = available ? 'ok' : 'taken';
} catch (err) {
if (err && err.name === 'AbortError') return;
status.textContent = 'error';
throw err;
}
});
One small UX thing: clearing validity/status when the field is empty prevents “Taken” from sticking around after backspacing.
Track a request id and ignore anything that isn’t the latest. Abort helps, but it doesn’t stop a response that already got far enough to resolve, so you need a second guard before you touch setCustomValidity() or the status text.
let controller;
let seq = 0;
input.addEventListener('input', async (e) => {
const value = e.target.value.trim();
controller?.abort();
if (!value) {
input.setCustomValidity('');
status.textContent = '';
return;
}
const mySeq = ++seq;
controller = new AbortController();
status.textContent = 'checking...';
try {
const res = await fetch(`/api/username?u=${encodeURIComponent(value)}`, {
signal: controller.signal
});
const { available } = await res.json();
if (mySeq !== seq) return;
input.setCustomValidity(available ? '' : 'Taken');
status.textContent = available ? 'ok' : 'taken';
} catch (err) {
if (err?.name === 'AbortError') return;
if (mySeq !== seq) return;
input.setCustomValidity('');
status.textContent = "can't check";
}
});
A small debounce on the input handler helps too. I usually keep it short, like 150–250ms, so you’re not firing off a request on every single keystroke.
Yep, this is the classic “out-of-order responses” race — abort() helps, but it can’t prevent a response that already resolved (or is about to) from running your setCustomValidity() line.
The pattern you posted (sequence/request id + “only update UI if I’m still the latest”) is the clean fix. We’ve got a similar write-up on kirupa here: https://www.kirupa.com/javascript/avoiding_race_conditions_in_javascript.htm