I’ve found yet another reason to hate zod in production. I associate it with
other bloatware, the likes of ORMs and heavy frameworks such as NestJS. Before I
digress into more ragebait ranting on this beloved library, I’d like to share
how schema.parse() caused a SEV-2 incident.
The Incident
One extra string
A React app I work on ships as a micro frontend into a much larger host application. My team owns both ends of the contract, and every response from those services gets validated with zod before it reaches any state. One of those responses is a map of note templates, each declaring which sections of a clinical note it belongs to.
const templateSchema = z.record(
z.string(),
z.object({
id: z.string(),
name: z.string(),
content: z.string(),
"note-sections": z
.enum(["history", "physical-exam", "assessment", "plan", "procedure"])
.array(),
}),
);
We added a new note section called revisit, one extra string in an array the
backend was already sending. The backend went out at 10:00 and the frontend
followed at 11:20 with revisit added to the enum. Additive changes are backwards
compatible, so that eighty-minute gap looked harmless.
An older client holding a zod schema disagreed. z.enum([...]) saw "revisit",
failed to find it in the list, and threw. The throw isn’t scoped to the
offending value or even to the template holding it, so every template the user
had went in the bin over one unfamiliar string. The UI was left with no data and
fell back to its error state. 2.39k users saw it and 41 filed tickets, even
though nothing had been lost and notes could still be saved.
Nobody refreshes
Users open the host application at the start of a shift and leave it open, and the tab survives from one shift to the next. By the time this happened we had people running bundles that were over two weeks old. Shipping the frontend at 11:20 therefore meant very little, since a new bundle on a server says nothing about the code in anybody’s browser. Support asked people to hard refresh and the tickets kept arriving.
Forcing one wasn’t an option either. The host prompts for a reload when its own version constant changes, roughly once a fortnight, so triggering it takes a code deploy and fires globally for every user of the product. Our bug didn’t clear that bar.
Knowing none of that yet, at 11:32 I shipped the obvious fix, dropping unknown values instead of throwing on them. It was a correct change that did nothing at all for the incident, since every broken user was broken precisely because they wouldn’t get a new frontend bundle for another fortnight. I had shipped a patch exclusively to the people who didn’t need it. We escalated to SEV-2 at 12:58, about ninety minutes after I thought I’d fixed it.
The backend had to lie
The only thing that reaches a bundle already running in somebody’s browser is
the data you send it, so the fix had to come from the backend. We put revisit
behind the same feature flag the frontend used, which stopped old bundles from
ever seeing the value that broke them. The change went out at 15:00 and errors
hit zero three minutes later. Five hours, resolved by teaching the backend to
pretend a shipped feature hadn’t shipped.
A deployed frontend is an open-ended population of versions running at once, each frozen at whatever moment its user last refreshed, so your backend can only ever be as new as the oldest bundle still open in somebody’s tab. Strict client validation turns every additive change into a synchronised deploy, and there is no synchronising with a tab that has been open since a fortnight last Tuesday.
The Trial of Zod
Zod has no jurisdiction here
Frontends don’t own the contract, so they have no business validating it this strictly. The Tolerant Reader pattern is the better fit. When the userbase keeps a tab open for a fortnight, it’s the only fit.
The version that shipped fails closed.
// fails closed: one unknown value destroys the whole response
"note-sections": z.enum([...]).array()
The tolerant version fails open and drops the values it doesn’t recognise.
// fails open: unknown values are dropped, the rest of the payload survives
"note-sections": z.preprocess(
value => (Array.isArray(value) ? value.filter(v => KNOWN.has(v)) : value),
z.enum(NOTE_SECTIONS).array(),
)
Both versions are equally wrong about the data. Only one of them escalates being wrong into a SEV-2.
The version I’d write today doesn’t validate at all.
type NoteSection =
| "history"
| "physical-exam"
| "assessment"
| "plan"
| "procedure"
| "revisit";
type Template = {
id: string;
name: string;
content: string;
"note-sections": NoteSection[];
};
type TemplatesResponse = Record<string, Template>;
const templates: TemplatesResponse = await fetch("/templates").then(r =>
r.json(),
);
This version doesn’t require a library, a schema, or a parse step, and nothing
in it can throw. A type is a compile-time artifact. It describes what I expect
and then evaporates, which is exactly the behaviour I want from a description of
somebody else’s response body.
The obvious objection asks what happens when the backend really does send
garbage. Then I find out at the point of use, on the one field that mattered,
rather than at the boundary on all of them at once. If a field genuinely
matters, guard it where you read it, where the blast radius is one component and
the fallback is a piece of UI you had to design anyway. In practice, this means
inside the then() block, with type guards (more on this later).
Earth needs only one Kryptonian
I carry one file across all my personal projects, and it covers everything I ever used zod for.
export type Guard<T> = (value: unknown) => value is T;
export const isBigint: Guard<bigint> = (value: unknown) => typeof value === "bigint";
export const isBoolean: Guard<boolean> = (value: unknown) => typeof value === "boolean";
export const isFunction: Guard<Function> = (value: unknown) => typeof value === "function";
export const isNumber: Guard<number> = (value: unknown) => typeof value === "number";
export const isString: Guard<string> = (value: unknown) => typeof value === "string";
export const isUndefined: Guard<undefined> = (value: unknown) => typeof value === "undefined";
export const isNull: Guard<null> = (value: unknown) => value === null;
export const isArray =
<T>(guard: Guard<T>): Guard<T[]> =>
(value: unknown): value is T[] =>
Array.isArray(value) && value.every(guard);
export const isOptional =
<T>(guard: Guard<T>): Guard<T | undefined> =>
(value: unknown): value is T | undefined =>
value === undefined || guard(value);
export const isOr =
<T extends unknown[]>(
...guards: {
[K in keyof T]: Guard<T[K]>;
}
): Guard<T[number]> =>
(value: unknown): value is T[number] =>
guards.some(guard => guard(value));
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (typeof value !== "object" || value === null) {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
};
export const isObject =
<T extends Record<string, unknown>>(schema: {
[K in keyof T]: Guard<T[K]>;
}): Guard<T> =>
(value: unknown): value is T => {
if (!isPlainObject(value)) {
return false;
}
for (const key in schema) {
if (!(key in value)) {
return false;
}
if (!schema[key](value[key])) {
return false;
}
}
return true;
};
export const isRecord =
<T>(guard: Guard<T>): Guard<Record<string, T>> =>
(value: unknown): value is Record<string, T> =>
isPlainObject(value) && Object.values(value).every(guard);
Composing the schema from the incident looks like this.
const NOTE_SECTIONS = [
"history", "physical-exam", "assessment", "plan", "procedure", "revisit",
] as const;
type NoteSection = (typeof NOTE_SECTIONS)[number];
const isNoteSection: Guard<NoteSection> = (value): value is NoteSection =>
isString(value) && (NOTE_SECTIONS as readonly string[]).includes(value);
const isTemplate = isObject<Template>({
id: isString,
name: isString,
content: isString,
"note-sections": isArray(isNoteSection),
});
const isTemplates = isRecord(isTemplate);
const data: unknown = await fetch("/templates").then(r => r.json());
if (!isTemplates(data)) {
logger.error("unexpected /templates shape");
}
I log it and carry on. Whether to degrade or bail is a decision I make at the call site, with the rest of the application in view. Zod makes that decision for me back at the schema, which may seem fine until you are summoned by an incident commander.
Zod never travels alone
Same two implementations, bundled and minified with esbuild and with Rollup.
// vite.lib.mjs
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: { entry: process.env.ENTRY, formats: ["es"], fileName: "b" },
outDir: process.env.OUTDIR,
minify: "esbuild",
},
});
npm i [email protected] esbuild vite
for impl in zod type-guards; do
npx esbuild src/$impl.ts --bundle --minify --format=esm --outfile=out/esbuild-$impl/b.js
ENTRY=src/$impl.ts OUTDIR=out/rollup-$impl npx vite build --config vite.lib.mjs
done
node -e 'const fs = require("fs"), z = require("zlib");
for (const d of fs.readdirSync("out").sort()) {
const b = fs.readFileSync(`out/${d}/${fs.readdirSync(`out/${d}`).find(f => /\.m?js$/.test(f))}`);
console.log(d.padEnd(20), b.length, z.gzipSync(b, { level: 9 }).length, z.brotliCompressSync(b).length);
}'
| Implementation | Bundler | Minified | + gzip | + brotli |
|---|---|---|---|---|
| zod | esbuild | 57.0 KB | 13.9 KB | 12.3 KB |
| zod | Rollup | 79.0 KB | 15.9 KB | 13.7 KB |
| type guards | esbuild | 501 B | 330 B | 276 B |
| type guards | Rollup | 659 B | 365 B | 306 B |
42x the size, to validate the same object. The schema uses z.record, z.object,
z.string, z.enum, and z.array, and the file you just read replaces all five in
330 bytes. Zod charges 13.9 KB for them, because v3 is built on a class
hierarchy that tree shaking can’t meaningfully take apart, making zod an
all-or-nothing dependency.
The entire premise of a micro frontend is small, independently deployable bundles dropped into a host. Spending 13.9 KB of that budget on a library whose job is to convert data I already control into an exception I don’t want is a bad trade.
Zod remakes the world in his image
The setup is the templates response from the incident, 500 templates and 454 KB of JSON, parsed with zod 3.23.8 and with the type guards above, on Node 24.
// bench.ts, run with: node --expose-gc bench.ts
const json = makeJson(500);
const settle = () => { gc(); gc(); gc(); };
for (const [name, fn] of Object.entries({
"JSON.parse only": () => JSON.parse(json),
"JSON.parse + guard": () => { const d = JSON.parse(json); isTemplates(d); return d; },
"JSON.parse + z.parse": () => templatesSchema.parse(JSON.parse(json)),
})) {
for (let i = 0; i < 30; i++) fn();
const times: number[] = [];
for (let i = 0; i < 200; i++) {
const t = performance.now();
fn();
times.push(performance.now() - t);
}
times.sort((a, b) => a - b);
const keep: unknown[] = [];
settle();
const base = process.memoryUsage().heapUsed;
keep.push(fn()); // hold it, or retained measures nothing
const peak = process.memoryUsage().heapUsed - base;
settle();
const retained = process.memoryUsage().heapUsed - base;
console.log(name, times[100], peak, retained);
}
| Approach | Time | Peak heap | Retained |
|---|---|---|---|
JSON.parse only |
0.339 ms | 563 KB | 542 KB |
JSON.parse + guard |
0.396 ms | 591 KB | 543 KB |
JSON.parse + z.parse |
0.561 ms | 3.2 MB | 606 KB |
The time column doesn’t support the rant. Zod costs four times what the type guard does, which comes to a fifth of a millisecond on a 454 KB payload. You would need roughly 40 of these to drop a single frame at 120 Hz.
The peak column is the interesting one. schema.parse() returns a new object
every time.
const output = templatesSchema.parse(input);
output === input; // false
output.tpl_0 === input.tpl_0; // false
output.tpl_0["note-sections"] === /* ... */; // false
Every object and every array is rebuilt, so mid-parse you hold a second complete
copy of the response on top of the one JSON.parse just made. 3.2 MB for a 454 KB
payload is more than two copies, because zod allocates a pile of throwaway
objects getting there. Almost all of it is garbage a moment later, which is why
the retained column barely changes. The type guard adds 28 KB, a minuscule
amount in comparison.
The lesser charges
.parse()throws,.safeParse()doesn’t, and zod gave the shorter name to the dangerous one.- Finding out which field broke means walking
issues[]and itspatharrays. - v3 to v4 was a rewrite, so now you migrate a dependency you never needed.
Don’t Kneel
I’m not telling anyone to rip zod out of their backend. At a real trust boundary, where the input is hostile, rejecting the whole payload is correct.
The frontend isn’t that boundary. The data arriving there came from a service my own team wrote. Validating it strictly doesn’t make it more correct. It adds a failure mode in the one part of the stack I can’t hotfix.
Zod gave me the feeling of having handled a whole class of problems. It took an unfamiliar seven-letter string and turned it into five hours, forty-one support tickets, and a SEV-2.