How to Async Refine with Zod
Use Zod refine with an async predicate to check uniqueness against a database, so the schema rejects duplicates before the form submit reaches the handler.
On this page
Introduction
The hardest part of check uniqueness against a store is not writing the code — it is deciding what the code is allowed to reject. A Zod async refine setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in Zod async refine, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
Most teams adopt Zod async refine for check uniqueness against a store after an incident teaches them that unvalidated input is load-bearing. The incident is the expensive way to learn the lesson; a quarterly review of the input shapes is the cheap way. Pin the shapes with Zod async refine, refuse the deploy that breaks the pin, and treat the pin as a contract the rest of the system can rely on. A contract that is not enforced is a wish.
The sections below walk through the concrete decisions: where to put the zod async refine check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.
Define the base schema
Define the base schema is the first decision because it determines what the rest of the Zod async refine pipeline can assume. A wrong choice here turns into check uniqueness against a store under load, and the invariant you thought Zod async refine guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Define the base schema is the one most teams skip: drive zod async refine to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
const UniqueEmail = z.string().email().refine(
async (email) => !await db.exists(email),
{ message: 'email already taken' }
);
The typescript above is the shape to ship for check uniqueness against a store. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.
Add an async refine
The mechanism behind Add an async refine is straightforward in Zod async refine, but the consequences ripple. A wrong choice here turns into check uniqueness against a store under load, and the invariant you thought Zod async refine guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Add an async refine is the one most teams skip: drive zod async refine to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Pass the lookup function
Pass the lookup function is where most Zod async refine implementations leak performance. A wrong choice here turns into check uniqueness against a store under load, and the invariant you thought Zod async refine guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Pass the lookup function is the one most teams skip: drive zod async refine to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
test('rejects duplicate email', async () => {
await expect(UniqueEmail.parseAsync('[email protected]')).rejects.toThrow();
});
Pin the behavior with this test. A Zod async refine setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Handle the rejection
The hard part of Handle the rejection is not the happy path — it is what happens when zod async refine fails halfway. A wrong choice here turns into check uniqueness against a store under load, and the invariant you thought Zod async refine guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Handle the rejection is the one most teams skip: drive zod async refine to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Cache the lookup result
Cache the lookup result should be observable without a debugger. A wrong choice here turns into check uniqueness against a store under load, and the invariant you thought Zod async refine guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Cache the lookup result is the one most teams skip: drive zod async refine to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Test with a stub store
Test with a stub store is a trade, not a winner. A wrong choice here turns into check uniqueness against a store under load, and the invariant you thought Zod async refine guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Test with a stub store is the one most teams skip: drive zod async refine to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Common pitfalls
The common pitfalls in check uniqueness against a store all reduce to one mistake: treating Zod async refine as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.
- Skipping the zod async refine boundary so check uniqueness against a store enters unvalidated.
- Assuming Zod async refine is fast without measuring the tail latency under load.
- Letting check uniqueness against a store fail silently instead of failing loudly with a metric.
- Testing only the happy path so the zod async refine failure surfaces in production.
- Sharing mutable state across requests so one bad input poisons the next.
Conclusion
Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: Zod async refine fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.
Self-check: write a test that drives zod async refine to the failing condition and assert the system degrades gracefully. Then check uniqueness against a store is a property you maintain, not a feature you hope for.
Sources: Zod, SuperRefine
FAQ
- Does async refine slow validation?
- It adds a round trip. Debounce on the client and cache the result to avoid re-querying on every keystroke.
- What if the database is down?
- Decide upfront: fail open (allow submit) or fail closed (block). Fail closed is safer for uniqueness.
Report a problem with this articleOpens a pre-filled issue on GitHub.