How do you debounce async field validation without showing stale errors?

Hey everyone, I’m wiring up a signup form and I’m trying to do async username checks without the UI flickering or showing the wrong message when requests finish out of order.

const input = document.querySelector('#username');
const status = document.querySelector('#status');
let t;
let lastReq = 0;

async function checkName(name) {
  const res = await fetch(`/api/username?u=${encodeURIComponent(name)}`);
  return res.json(); // { ok: boolean, reason?: string }
}

input.addEventListener('input', () => {
  clearTimeout(t);
  const name = input.value.trim();
  t = setTimeout(async () => {
    const reqId = ++lastReq;
    status.textContent = 'Checking…';
    const result = await checkName(name);
    if (reqId !== lastReq) return; // ignore stale
    status.textContent = result.ok ? 'Available' : (result.reason || 'Taken');
  }, 250);
});

What’s the cleanest pattern here to avoid stale responses and also not leak work (AbortController, request id, something else) when validation is happening constantly while the user types?

1 Like

Use AbortController here. It stops the old request instead of just pretending it never happened, which matters once people type fast and your backend starts eating junk traffic.

I’d keep the request id too. Abort handles the network side, and the id keeps you from updating the UI if some older async path still finishes late for whatever reason.

const input = document.querySelector('#username');
const status = document.querySelector('#status');

let t;
let controller;
let runId = 0;

async function checkName(name, signal) {
  const res = await fetch(`/api/username?u=${encodeURIComponent(name)}`, { signal });
  return res.json(); // { ok: boolean, reason?: string }
}

input.addEventListener('input', () => {
  clearTimeout(t);

  const name = input.value.trim();
  const myRun = ++runId;

  if (!name) {
    controller?.abort();
    status.textContent = '';
    return;
  }

  t = setTimeout(async () => {
    if (myRun !== runId) return;

    controller?.abort();
    controller = new AbortController();

    status.textContent = 'Checking…';

    try {
      const result = await checkName(name, controller.signal);
      if (myRun !== runId) return;

      status.textContent = result.ok ? 'Available' : (result.reason || 'Taken');
    } catch (e) {
      if (e?.name === 'AbortError') return;
      if (myRun !== runId) return;

      status.textContent = 'Error checking name';
    }
  }, 250);
});

One small thing: I’d make sure the server treats aborted requests as normal noise and doesn’t log them like failures. That stuff gets annoying fast.

1 Like