Zod’s .refine() and .superRefine() are deceptively simple APIs — but async variants carry a cluster of runtime traps that don’t surface until production. A single async callback in the wrong place can expose your backend to a Denial of Service (DoS) vulnerability, crash your parser with a fatal exception, or silently fire the same database query twice on every form submission.
- Calling
.parse()on a schema with an async.refine()throws a hard runtime exception — TypeScript won’t catch it at compile time (Zod official docs, 2025). - Zod’s eager evaluation model fires all
.refine()callbacks even when earlier validators like.min()or.max()have already failed, creating a direct DoS vector (Shinde, 2026). - Switching from
.parse()to.parseAsync()on large datasets can be 100x slower — at 200,000 elements, async parsing took 22 seconds vs. 1.3 seconds synchronously (GitHub issue #3446, colinhacks/zod). - Three remediation patterns —
.superRefine()withz.NEVER,.pipe()chaining, and two-phase decoupled validation — each eliminate the vulnerability with different complexity/throughput tradeoffs.

How Does Zod’s Async Parse Engine Actually Work?
Zod’s official documentation is clear but easily missed: “If you use async refinements, you must use the .parseAsync method to parse data! Otherwise Zod will throw an error” (Zod API docs, 2025). Understanding why requires a look at the internal execution context.
When you chain .refine() or .superRefine() onto a base validator, Zod wraps the schema inside a class called ZodEffects. This effect node intercepts and transforms data during the parse cycle. Two parallel evaluation pathways exist: synchronous (.parse(), .safeParse()) and asynchronous (.parseAsync(), .safeParseAsync()). The internal parsing context tracks which path is active using a boolean flag — ctx.common.async.
Here’s the failure mode in its simplest form:
import { z } from "zod";
const UserSchema = z.object({
username: z.string().refine(async (val) => {
return await checkDatabaseUniqueness(val);
})
});
// THROWS RUNTIME EXCEPTION:
// "Async refinement encountered during synchronous parse operation. Use .parseAsync instead."
UserSchema.parse({ username: "alex" });
// CORRECT ASYNCHRONOUS EXECUTION:
await UserSchema.parseAsync({ username: "alex" });The critical detail: this is not a TypeScript compile-time error. The type system gives you no warning. The failure is silent until the code path executes at runtime — which, in a framework integration that defaults to synchronous handlers, might be the first real request in production.
Zod’s internal parser evaluates the return value of any refinement callback. If the result is a Promise instance while ctx.common.async is false, the parser immediately throws a fatal JavaScript exception. This behavior prevents silent discarding of unawaited async checks but produces no compile-time signal, shifting the failure entirely to runtime (Zod async parsing internals, 2025).
Framework integrations are the hidden amplifier here. When third-party libraries like Vercel AI SDK or form state managers invoke `.parse()` internally on your schema (GitHub vercel/ai #4927), you lose control of which parse method gets called. Introducing an async `.refine()` anywhere in a schema used by such integrations silently breaks the pipeline.
Why Does .refine() Produce Those Useless “Invalid input” Errors?
In 2025, async refinement failures frequently surfaced with a generic error object that told developers almost nothing:
[
{
"code": "custom",
"path": [],
"message": "Invalid input."
}
]The root cause is architectural. The standard .refine() method accepts a boolean predicate as its first argument. Because the predicate can only return true or false, it can’t communicate why validation failed — or which field failed it. Zod falls back to a minimal default issue object when no params configuration is provided.
The error code itself — ZodIssueCode.custom (Zod 3) or z.core.$ZodIssueCustom (Zod 4) — is a generic classification with no domain-specific semantics. When validating an object schema, the problem compounds: unless you explicitly pass path: ["fieldName"] inside RefineParams, Zod attaches the error to the root level (path: []) rather than the specific property that failed.
The API changed between major versions, adding another failure surface. In Zod 3, dynamic messages use (val) => ({ message: \Invalid ParseError: KaTeX parse error: Expected group as argument to ‘\`’ at position 8: {val}\` ̲})`. In Zod 4, …{issue.input}“. Teams upgrading without reading the changelog ship broken error messages silently.
The Hidden DoS Vulnerability in Ungated Async Refinements
This is the most dangerous pitfall — and the least obvious. Zod’s evaluation model prioritizes comprehensive error reporting over short-circuit efficiency. It doesn’t stop at the first failure; it collects all errors in a single execution pass.
The consequence: .min(), .max(), and .email() validators record a failure but don’t halt execution. The .refine() callback runs regardless.
// UNSAFE PATTERN: Ungated Execution Vector
const UnsafeUserSchema = z.object({
username: z
.string()
.min(3, "Username too short")
.max(20, "Username too long")
.refine(async (val) => {
// EXECUTES EVEN IF .min() OR .max() FAILS
return await db.users.isUnique(val);
}, { message: "Username already taken", path: ["username"] })
});If an attacker submits a 10-megabyte text payload, the .max(20) check fails immediately — but the async database uniqueness check still fires. Every malformed request triggers a real DB query. The attack is asymmetric: the attacker pays near-zero bandwidth cost, your database pays full query cost for garbage input.
In 2026, Hrushikesh Shinde documented three system failure modes that emerge from this pattern in production environments (How Zod’s .refine() Can Cause a Denial of Service, 2026):
- Database connection pool exhaustion — High-frequency malformed payloads occupy active connections, starving legitimate traffic.
- Upstream rate limit burnout — Async refinements calling third-party APIs rapidly exhaust API quotas on invalid requests.
- Event loop latency spikes — The Node.js thread pool saturates with microtask promises from unnecessary async executions, causing p99 latency spikes across the entire platform.
Zod’s eager evaluation model causes un-gated asynchronous refinements to execute even when preliminary structural assertions fail, exposing backend systems to application-layer Denial of Service vectors. The attack surface is unauthenticated: any public endpoint accepting a Zod-validated body that contains an async .refine() touching a database or external API is potentially exploitable with trivially malformed payloads.
The Double-Execution Bug That Breaks OTP Flows
A separate class of bug emerges at the form library boundary. In 2025, a confirmed GitHub issue (TanStack/form #1431) documented that async .refine()/.superRefine() validators wired into third-party form libraries — React Hook Form via @hookform/resolvers, TanStack Form — execute their network-calling logic twice per single user action, causing duplicate side-effecting requests.
The issue reporter described it precisely: “The network request initiated by the checkUser function appears twice in the browser’s Network tab for a single form submission.”.
For OTP verification flows, this isn’t just inefficient — it’s broken. The first validation call consumes the OTP code, making it invalid. The second call fails. Users get permanently stuck. A follow-up dev.to writeup framed the root cause explicitly: “Validation is not a pure function anymore… This breaks the expectation that validation is idempotent,” and documented the real-world OTP impact: “First code becomes invalid — Users get stuck.” (Eliminating Double Async Validation in TanStack Form & Zod, dev.to, 2025).
The fix: manually call schema.safeParseAsync(value) inside the submit handler instead of passing the schema object directly to the library’s async validator slot, combined with a useRef re-entrancy guard. Move all side effects fully out of the validation layer.
The 100x Performance Regression of parseAsync
Here’s the trade-off nobody mentions when suggesting “just use .parseAsync()“: it’s catastrophically slow for large datasets in Zod v3.
In 2024, GitHub issue #3446 on colinhacks/zod provided raw benchmark data that shocked the community (Huge memory and performance gap between parse and parseAsync):
- At 200,000 elements: sync
.parse()took 1.273s..parseAsync()took 22.556s — a 17× gap. - At 250,000 elements: sync took 1.746s. Async took 1 minute 51 seconds — a 63× gap.
- At higher counts:
.parseAsync()caused full Node.js process OOM crashes while.parse()stayed around 300MB.
The maintainer, Colin McKinney, explicitly acknowledged the regression: “I’m broadly aware of this and am working on solutions in Zod 4… obvious performance issues in Zod’s current parsing pipeline.” (GitHub issue #3446, 2024).
The root cause is Promise-wrapping overhead tied to the ctx.async flag — every node in the parse tree gets wrapped, even nodes with no async operations. Zod v4 rearchitected the parsing pipeline and delivered significant improvements, though a residual ~2x overhead persists. The OOM ceiling moved from ~300k rows to ~700k–1.2m rows — improved, but not eliminated.
Three Production-Grade Remediation Patterns
Pattern 1: .superRefine() with fatal: true and z.NEVER
The .superRefine() method exposes low-level access to the refinement context (RefinementCtx). Combining ctx.addIssue() with the fatal: true modifier and returning z.NEVER halts evaluation immediately when preliminary checks fail.
import { z } from "zod";
export const SafeUsernameSchema = z.string().superRefine(async (val, ctx) => {
// 1. Synchronous Structural Guard — runs first, costs nothing
const structuralCheck = z.string().min(3).max(20).safeParse(val);
if (!structuralCheck.success) {
// Re-emit structural issues into current context with precise field paths
structuralCheck.error.issues.forEach((issue) => ctx.addIssue(issue));
// Abort pipeline immediately; skips expensive database operations
return z.NEVER;
}
// 2. Controlled Asynchronous Verification — only reaches here on valid input
const isTaken = await db.users.isUnique(val);
if (!isTaken) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Username is already registered",
fatal: true
});
return z.NEVER;
}
});Returning z.NEVER signals to Zod’s parser that field evaluation has failed fatally, preventing subsequent chained transforms or downstream refinements from processing invalid states.
Pattern 2: Schema Pipeline Chaining via .pipe()
Zod’s .pipe() method connects a source schema to a target schema in a strictly ordered, two-stage evaluation pipeline. If the source schema fails, evaluation halts completely and the target — with its async refinements — never executes.
import { z } from "zod";
// Stage 1: Pure Structural Schema (Synchronous Guard) — fast, zero I/O
const BaseUserSchema = z.object({
email: z.string().email("Invalid email format"),
age: z.number().min(18, "Must be an adult")
});
// Stage 2: Asynchronous Business Rule Schema — only reached on clean input
const AsyncUserSchema = z.object({
email: z.string(),
age: z.number()
}).superRefine(async (data, ctx) => {
const accountExists = await checkEmailInDb(data.email);
if (accountExists) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["email"],
message: "Email is already registered"
});
}
});
// The pipeline gates Stage 2 behind successful Stage 1 execution
export const UserRegistrationSchema = BaseUserSchema.pipe(AsyncUserSchema);This is the cleanest API for expressing two-phase validation — the structural intent is visible at the schema definition level, not buried in conditional logic.
Pattern 3: Two-Phase Architectural Validation (Decoupled)
For high-throughput systems, the most robust approach decouples validation concerns across architectural layers entirely:
import { z } from "zod";
// Phase 1: Pure Synchronous Boundary Schema — lives at the API controller layer
export const PureUserSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email()
});
export type ValidatedUserInput = z.infer<typeof PureUserSchema>;
// Phase 2: Application Service Layer Handler — async business logic, separate concern
export async function registerUserService(rawInput: unknown) {
// Phase 1 fails fast without any database interaction
const parsedData = PureUserSchema.parse(rawInput);
// Phase 2 executes async semantic validation only after structural success
const isUnique = await db.users.checkUniqueness(parsedData.username, parsedData.email);
if (!isUnique) {
throw new DomainValidationError("Username or Email is already in use");
}
return await db.users.create(parsedData);
}This pattern eliminates the DoS vulnerability entirely, preserves clean error domain boundaries, and avoids unhandled async runtime errors. The tradeoff: higher implementation complexity and loss of the schema-as-single-source-of-truth property.
Choosing the Right Pattern: Strategy Comparison
| Dimension | Standard .refine() (Async) | .superRefine() + z.NEVER | .pipe() Pipeline | Two-Phase Decoupled |
|---|---|---|---|---|
| Short-circuit capability | None | Via fatal: true | Gated by source schema | Phase 1 halts execution |
| DoS vulnerability risk | High | Low | Minimal | Zero (complete isolation) |
| Error field localization | Poor (root-level) | Precise (ctx.addIssue) | Precise (ctx.addIssue) | Native service layer |
| Double-execution exposure | High (form libs) | Moderate | Moderate | None |
| Implementation complexity | Low | Moderate | Moderate | High |
Requires .parseAsync() | Yes | Yes | Yes | No (Phase 1 is sync) |
Conclusion
Zod’s async refinement API is powerful but carries four compounding failure modes that only surface in production: a runtime exception when mixing sync parse methods with async schemas, generic "Invalid input" errors useless for UI mapping, a direct application-layer DoS vulnerability from eager evaluation, and silent double-execution bugs in form library integrations.
The remediation path depends on your constraints. Use:
-
.superRefine()withz.NEVERfor granular control within a single schema. -
.pipe()chaining for clean two-stage validation with visible structural intent. - Two-phase decoupled validation for high-security backends where zero DoS exposure is non-negotiable.
And always validate on the server — client-side async refinements are UX, not security.
FAQs
.refine() accepts a boolean predicate and returns a single error on failure, making it simple but limited for complex scenarios. .superRefine() gives direct access to RefinementCtx, letting you call ctx.addIssue() with precise field paths, multiple issues, and the fatal: true flag that halts further evaluation. For any async validation, .superRefine() is the correct choice — it provides the control needed to prevent ungated execution and produce meaningful, localized errors. .parse() or .safeParse() is called on a schema containing an async .refine(), .superRefine(), or async transform. TypeScript doesn’t catch this at compile time. Always use .parseAsync() or .safeParseAsync() with async schemas, or restructure using the two-phase pattern where Phase 1 uses a purely synchronous schema with .parse(). .refine() callbacks still execute even when prior validators fail. The DoS vulnerability from ungated async refinements requires architectural remediation regardless of Zod version. await schema.safeParseAsync(value) inside your submit handler. Add a useRef-based re-entrancy guard to prevent concurrent validation calls. Move all side effects — database queries, API calls — out of the .refine() layer entirely and into your submit handler or service layer. This ensures each user action produces exactly one validation pass with one round of side effects. Related Citations & References
- GIGitHub – colinhacks/zod: TypeScript-first schema validation with static type inference · GitHub
- HRHow Zod's .refine() Can Cause a Denial of Service — And How to Fix It (2026)
- ZOIntro | Zod
- V3Zod | Documentation
- BLAsync Operations With Zod Refine And Superrefine Methods 2b24dafc1d84
- GIOption to create params from input in custom validation · Issue #88 · colinhacks/zod · GitHub
- ERAsync refinement encountered during synchronous parse operation. Use .parseAsync instead. — colinhacks/zod | ErrLookup
- STRefining Types with Zod | Full Stack TypeScript | Steve Kinney
- ZOBasic usage | Zod
- GISupport zod parseAsync · Issue #4927 · vercel/ai · GitHub
- STHow Do I Modify The Default Error Message When Using Rhf With Zod Object And Ref
- ZOMigration guide | Zod
- ZODefining schemas | Zod
- STZod Refine Unknown Error Message When Applied To More Paths
- GIsuperRefine Error Path · colinhacks zod · Discussion #2035 · GitHub
- GI[question] is it possible to stop parsing on the first error? · Issue #1403 · colinhacks/zod · GitHub
- GI[question] is it possible to stop parsing on the first error? · Issue #1403 · colinhacks/zod · GitHub
- STHow To Set The Error Message In Zod Refine Method
- GIrefine() function gets called even when parser already failed within translate()/refine()/regex() · Issue #2192 · colinhacks/zod · GitHub




