The exact problem: a Zod schema needs to check that an email address is not already registered, and the obvious implementation — an async refinement inside the schema used for every keystroke — fires a request per character and makes safeParse a network call.
Context and Prerequisites
The synchronous half of this is in integrating Zod for schema validation, and the sequencing in asynchronous validation strategies. The key structural decision is made before any code: the async check does not belong in the schema the form validates on every change.
Core Pattern: Two Schemas, One Definition
import { z } from 'zod';
// The schema the form uses on every change and on every step. Fully
// synchronous, so safeParse stays a pure function and costs microseconds.
export const signupSchema = z.object({
email: z.string().email('Enter an address we can reach you at'),
password: z.string().min(12, 'Use 12 characters or more'),
});
/**
* The schema used at submit, and only there. superRefine's callback may be
* async, which makes the whole schema async — hence parseAsync, and hence the
* separation: nothing that runs per keystroke should be able to await.
*/
export const signupSubmitSchema = signupSchema.superRefine(async (values, ctx) => {
const taken = await isEmailTaken(values.email);
if (taken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
// The path is what attaches the message to a field. Omit it and the
// issue lands on the object root, where no input can render it.
path: ['email'],
message: 'That address is already registered',
});
}
});
// At submit — note parseAsync/safeParseAsync, not safeParse.
const result = await signupSubmitSchema.safeParseAsync(values);
An async refinement makes the entire schema async: safeParse throws rather than returning a result. That is the mechanism forcing the separation, and it is a good one — it makes the expensive path impossible to call by accident.
Step-by-Step Walkthrough
-
Keep the base schema synchronous. It is the one that runs constantly.
-
Extend, do not modify.
signupSchema.superRefine(...)reuses every field rule by reference, so nothing can drift. -
Always set
path. An issue with no path lands on the object root and no field renders it. -
Debounce the field-level check separately. The submit-time refinement is the guarantee; a debounced check on blur is the courtesy that stops the reader reaching submit to find out.
-
Treat a failed check as unknown, not invalid. If the request errors, do not mark the field invalid — let the server decide.
-
Share the submit schema with the server. It is the same check; running it in both places from one definition is most of the value.
Failure Modes and Edge Cases
1. safeParse on an async schema
It throws rather than returning a result. The fix is always to call the right schema; a try/catch around it hides a structural mistake.
2. Refinements do not run when the base fails
An async refinement only runs if the object parsed. That is usually right — no point asking whether a malformed address is taken — but it means the remote check is silently skipped whenever anything else is invalid.
3. No cancellation inside a refinement
Zod does not thread an AbortSignal. Close over one from the caller, and check it inside the refinement before issuing the request.
4. Several async refinements
They run concurrently within one parse, so three remote checks are three simultaneous requests. That is usually fine at submit and never fine per keystroke.
5. The message differs between client and server
If the server has its own copy of the rule with different wording, the reader sees one message on blur and another after submit. Sharing the schema fixes it.
Verification Checklist
Common Pitfalls
- One schema for both paths. An async refinement makes the whole schema async, so the change handler becomes a promise and a network call per keystroke.
- Omitting the issue
path. The issue lands on the object root, no field renders it, and the submit fails with nothing visible. - Treating a failed request as invalid. A network problem is not the reader’s mistake, and marking the field invalid blocks a submit the server would have accepted.
- Assuming the refinement ran. Refinements only run after the base object parses, so any other invalid field silently skips the remote check.
- Duplicating the rule on the server. Two copies of a uniqueness rule with different wording means the reader sees one message on blur and another after submit.
Related
- Integrating Zod for Schema Validation — the synchronous half
- Discriminated Unions for Conditional Schemas — the other structural refinement
- Queueing Async Validators in Order — where the debounced field check runs
← Integrating Zod for Schema Validation
Frequently Asked Questions
Why not put the async check in the schema the form validates with?
Because any async refinement makes the entire schema async, so every change-handler call becomes a promise and a network round trip. The reader would get one request per keystroke and the form would lose its synchronous validity answer, which the submit button and the step guard both depend on. Keeping the base synchronous and extending it for submit gives you one definition and two costs.
Do async refinements run if another field is invalid?
No. Refinements run only after the base object parses successfully, so a malformed email means the uniqueness check never fires. That is usually the behaviour you want — there is no point asking whether an invalid address is taken — but it does mean the remote result is absent rather than passing, and code reading the result should not treat its absence as success.
How do I cancel an in-flight refinement?
Zod does not thread an AbortSignal through refinements, so close over one from the caller and check it at the top of the refinement before issuing the request. On submit that is rarely needed, since the reader is waiting for the result anyway. It matters for the debounced field-level check, which is not a refinement at all — it is a plain validator running through the validation queue.