Users edit back and forth — type a username, delete a letter, retype it — and an uncached async validator sends a request for every value it settles on, so the same “is ada_l available?” question hits the server three times in ten seconds, the field flickers through “Checking…” each time, and rate limits arrive sooner than they should.

A small cache turns repeated questions into instant answers. The subtle part is deciding what can be cached and for how long, because some answers change (a username can be taken a minute later) and some never do (a postcode’s format validity). This page builds a cache for async validators, part of the asynchronous validation strategies topic, and pairs it with the cancellation patterns already covered there.


Context and prerequisites

Three kinds of async answer, with different cache lifetimes:

  • Stable facts — “is this postcode real?”, “does this VAT number exist?”, “what city is this postcode in?” Answers change rarely; cache for the session or longer.
  • Volatile availability — “is this username/email free?” A taken answer is stable (it will stay taken); an available answer can become wrong at any moment. Cache “taken” longer than “available”, and always re-check at submit.
  • Rate- or error-dependent — timeouts, 429s, 5xx. These are not answers about the value; never cache them as if they were.

Two techniques combine: a result cache (value → answer with an expiry) and in-flight sharing (value → pending promise), so two validators asking the same question at the same time send one request.

Editing a username with and without a cache The user settles on ada_l at one second, and both versions send a request. The user deletes the l and settles on ada_ at three seconds; both send a request for the new value. The user retypes l and settles on ada_l again at five seconds. Without a cache a third request is sent and the field shows checking again. With a cache the earlier answer for ada_l is reused instantly with no request. No cache ada_l ada_ ada_l again With cache ada_l ada_ cached, instant 0ms 1000ms 2000ms 3000ms 4000ms 5000ms 6000ms 7000ms

The core pattern: a TTL cache with in-flight sharing

type Answer = { ok: true } | { ok: false; reason: "taken" | "invalid" };

interface Entry { answer: Answer; expires: number }

export function createValidationCache(opts: {
  ttlPositiveMs: number;          // how long "available/valid" is trusted
  ttlNegativeMs: number;          // how long "taken/invalid" is trusted (usually longer)
  maxEntries?: number;
  normalise: (v: string) => string;
}) {
  const results = new Map<string, Entry>();
  const inflight = new Map<string, Promise<Answer>>();
  const max = opts.maxEntries ?? 200;

  function remember(key: string, answer: Answer) {
    const ttl = answer.ok ? opts.ttlPositiveMs : opts.ttlNegativeMs;
    results.delete(key);                       // re-insert to refresh LRU order
    results.set(key, { answer, expires: Date.now() + ttl });
    if (results.size > max) results.delete(results.keys().next().value!);   // evict oldest
  }

  return {
    async check(raw: string, fetchAnswer: (v: string, signal: AbortSignal) => Promise<Answer>, signal: AbortSignal) {
      const key = opts.normalise(raw);

      const hit = results.get(key);
      if (hit && hit.expires > Date.now()) return hit.answer;          // instant, no request

      // Share an in-flight request for the same key. Each caller still honours
      // its OWN signal: abandoning the wait does not cancel the shared request
      // for other callers.
      let p = inflight.get(key);
      if (!p) {
        const shared = new AbortController();   // the shared request's own lifetime
        p = fetchAnswer(key, shared.signal)
          .then((a) => { remember(key, a); return a; })   // only real answers are cached
          .finally(() => inflight.delete(key));
        inflight.set(key, p);
      }
      return raceAbort(p, signal);
    },
    invalidate(raw?: string) { raw === undefined ? results.clear() : results.delete(opts.normalise(raw)); },
  };
}

function raceAbort<T>(p: Promise<T>, signal: AbortSignal): Promise<T> {
  if (signal.aborted) return Promise.reject(signal.reason);
  return new Promise<T>((resolve, reject) => {
    const onAbort = () => reject(signal.reason);
    signal.addEventListener("abort", onAbort, { once: true });
    p.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
  });
}
const usernames = createValidationCache({
  ttlPositiveMs: 30_000,        // "available" trusted for 30 s
  ttlNegativeMs: 10 * 60_000,   // "taken" trusted for 10 min
  normalise: (v) => v.trim().toLowerCase(),
});
// In the field's validator, with a per-keystroke AbortController:
// const answer = await usernames.check(value, fetchAvailability, controller.signal);

Step-by-step walkthrough

  1. Normalise the key the way the server compares. If the server treats usernames case-insensitively, Ada_L and ada_l must share a cache entry.
  2. Return cached answers synchronously in effect. A hit resolves immediately, so the field never shows “Checking…” for a value it has already seen — see accessible pending state for async validation.
  3. Share in-flight requests. Two triggers asking about the same value (blur and a debounced change, or two fields using the same lookup) wait on one promise.
  4. Keep cancellation per caller. Each caller races the shared promise against its own AbortSignal, preserving the stale-result protection from cancelling stale async validation with AbortController.
  5. Cache only real answers, with asymmetric TTLs. “Taken” rarely becomes “available”; “available” can become “taken”. Errors, timeouts and 429s are never cached.
  6. Invalidate on known changes. After the user successfully registers a username, invalidate it; after a failed submit says “taken”, overwrite the entry.

Why the submit must still check

A cache makes the form feel faster and reduces load, but it cannot make a volatile answer true. Between the cached “available” and the submit, another user may take the username; between a cached “valid VAT number” and the submit, the business may have deregistered. The server’s check at submit is the authority, and its answer should overwrite the cache entry. Treat the cache as a way to avoid asking the same question twice in quick succession — not as a way to avoid asking at the moment it matters.

What to cache, and for how long Postcode or address existence can be cached for the session. VAT or company number validity can be cached for the session. Username or email taken can be cached for several minutes. Username or email available should be cached briefly, around thirty seconds, and re-checked at submit. Timeouts, 429 rate limits and 5xx errors should never be cached as answers. Answer Cache for Notes postcode / address exists session stable facts VAT or company number valid session re-check at submit username taken minutes rarely becomes free username available ~30 s can be taken any moment timeout, 429, 5xx never not an answer about the value

Failure modes and edge cases

1. Caching errors as answers

If a 503 is stored as “invalid”, the user is blocked until the entry expires, even after the service recovers. Only resolved domain answers enter the cache; failures are handled by handling timeouts and 429s in async validators.

2. Shared request cancelled by one caller

If the shared fetch used the first caller’s signal, that caller’s next keystroke would cancel the request for everyone waiting on it. Give the shared request its own controller and let callers only stop waiting.

3. Unbounded growth

A cache keyed by every value typed into a search-like field grows without limit on long sessions. Bound it (LRU with a maximum size) and prefer short TTLs for high-cardinality keys.

4. Cross-user leakage

A module-level cache persists across users on the same device only if the app keeps running between sign-ins. Clear validation caches on sign-out, especially for answers that depend on the user’s permissions.

5. Server-side caching instead

HTTP caching (Cache-Control: max-age on GET lookups) can achieve much of this without client code, for stable facts. Keep volatile availability answers no-store at the HTTP layer and let the client cache decide briefly.

Two triggers sharing one request The debounced change validator asks the cache about ada_l; there is no entry, so the cache starts a request and records it as in flight. A moment later the blur validator asks about the same value; the cache returns the same pending promise instead of sending a second request. The server answers taken. The cache stores taken with a long expiry and both validators receive the answer. Change validator Blur validator Cache Server check("ada_l") GET /available?u=ada_l check("ada_l") → same promise taken taken (cached 10 min)

Verification checklist


Frequently Asked Questions

Should the cache persist across page loads?

Rarely. Stable facts could be persisted in sessionStorage, but availability answers go stale quickly, and a persisted “available” is misleading next time. An in-memory cache for the life of the page covers the back-and-forth editing that motivates caching.

Does TanStack Query or SWR solve this?

Largely, yes: they cache by key, deduplicate in-flight requests, and support stale times. Wrap your availability lookup in a query keyed by the normalised value, set staleTime per answer type, and keep the per-keystroke cancellation behaviour in your validator.

How do I test the cache?

Use fake timers to move past TTLs, a mocked fetch that counts calls, and assertions that repeated checks within the TTL make one call — the techniques in testing debounced validation with fake timers.


Related

← Asynchronous Validation Strategies