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.

The synchronous schema is the one that runs constantly One base object schema defines every field and every synchronous rule. It runs on change and on blur, costs microseconds, and is safe to call as often as the form likes. Extending it with an async superRefine produces a second schema that adds the remote check. Because any async refinement makes the whole schema async, that second schema can only be called with safeParseAsync — which means it cannot be invoked from a synchronous change handler by accident. It is used at submit, and by the server, which is the other consumer that has to run the same remote check. signupSchema every field, sync rules safeParse — microseconds on change, on blur as often as you like .superRefine(async …) the remote check safeParseAsync only at submit, and on the server Any async refinement makes the WHOLE schema async — safeParse throws on it. That is the guard, not a limitation. It makes calling the expensive schema from a change handler a type error rather than a performance incident. Which parse entry point to call, and when safeParse is synchronous and returns a result object; it is what the change handler and the step guard call, on the base schema. parse is synchronous and throws; it belongs at a trust boundary such as reading configuration, not in a form. safeParseAsync returns a promise of a result object and is what submit calls on the extended schema. parseAsync throws asynchronously and has the same narrow use as parse. Calling a synchronous entry point on a schema with an async refinement throws, which is the guard rather than a trap. Entry point Returns Called by safeParse a result object change handlers and step guards parse the value, or throws trust boundaries, not forms safeParseAsync a promise of a result submit, on the extended schema parseAsync a promise, or rejects rarely — same use as parse Calling safeParse on a schema carrying an async refinement throws — which is how the expensive path stays uncallable by accident.

Step-by-Step Walkthrough

  1. Keep the base schema synchronous. It is the one that runs constantly.

  2. Extend, do not modify. signupSchema.superRefine(...) reuses every field rule by reference, so nothing can drift.

  3. Always set path. An issue with no path lands on the object root and no field renders it.

  4. 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.

  5. Treat a failed check as unknown, not invalid. If the request errors, do not mark the field invalid — let the server decide.

  6. 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.

Two checks of the same rule, at two moments On blur, a debounced validator runs the remote check through the validation queue and reports early, so the reader is not surprised at the end. On submit, the extended schema runs the same check as a refinement and is the actual guarantee. The two use one implementation of the check itself, so the wording and the verdict agree, and the early one is a courtesy that the late one does not depend on. Two checks of the same rule, at two moments on blur debounced, through the validation queue reported early the reader is not surprised at the end on submit the same check, as a schema refinement the guarantee and the server runs the same schema One implementation of the check, called from two places — otherwise the blur message and the submit message drift apart.

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

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.