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.
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
- Normalise the key the way the server compares. If the server treats usernames case-insensitively,
Ada_Landada_lmust share a cache entry. - 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.
- 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.
- 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. - Cache only real answers, with asymmetric TTLs. “Taken” rarely becomes “available”; “available” can become “taken”. Errors, timeouts and 429s are never cached.
- 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.
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.
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.