💎 Zod 4.5 is out!  Read the announcement.

Reducing Zod's memory footprint by an order of magnitude with memoizing prototypes

Colin McDonnell··2 min read

Zod 4.5 implements a "method memoization" pattern that allows it to defer allocating memory for bound methods until they are used (if ever). This post discusses that pattern.

In Zod 4.4 a bare z.string() retained 7.5kb of heap. In Zod 4.5 it retains 784 bytes.

Bar chart of heap retained by one schema instance, zod 4.4.3 versus 4.5: a 10-key object 82.0kb to 11.0kb, a union 17.5kb to 2.13kb, z.string().min(1) 16.7kb to 3.37kb, a record 16.4kb to 2.64kb, z.string().optional() 12.6kb to 1.50kb, an array of strings 11.2kb to 1.93kb, z.string() 7.53kb to 784b, z.number() 4.44kb to 706b. Up to 9.8x smaller than 4.4.3.
Retained heap per schema instance, Zod 4.4.3 vs 4.5 (benchmark)

The problem

By default all methods on Zod schemas are auto-bound, meaning they do not rely on implicit this semantics to work properly. That enables patterns like this, which otherwise may break or behave in unexpected ways when reliant on unbound prototype methods.

const { parse } = z.string();
 
parse("some data");

This is often useful for preventing unexpected behavior in certain uncommon circumstances. There's a package on npm that specifically implements some auto-bind utilities. But there's a downside, which is that every method is now a bound closure that takes up space in memory. It's no longer possible for multiple instances of one class to share a method via regular prototype chain inheritance.

The fix

This is now conclusively fixed in Zod 4.5 via a pattern I've been calling "method memoization".

Zod classes define their methods as getters on a standard prototype. The first time an instance's method is accessed, it falls through to the prototype via standard prototype inheritance. The getter returns a method implementation that's bound to the accessing instance and assigns it as an own property to the instance, effectively memoizing it forever. Successive accesses on that same method thus avoid getter-invocation overheads and resolve directly from the own property.

It's a more sophisticated variant of the self-overwriting getter I've described before.

An instance method that's never used (and most aren't!) is never materialized, nor is it ever stored as an own property on the instance.

The results

schema4.4.34.5smaller
z.string()7.5kb784b9.8x
z.number()4.4kb706b6.4x
z.boolean()3.6kb594b6.3x
z.literal("a")5.1kb1.7kb3.0x
z.enum([...])5.3kb1.5kb3.5x
z.email()5.8kb2.2kb2.7x
z.uuid()5.9kb2.2kb2.6x
z.iso.datetime()6.1kb2.5kb2.4x
z.string().min(1)16.7kb3.4kb5.0x
z.string().min(1).max(5)25.8kb6.0kb4.3x
z.string().optional()12.6kb1.5kb8.4x
z.string().nullable()13.1kb1.5kb8.8x
z.string().default("")13.1kb1.9kb7.1x
z.string().brand()7.6kb872b8.9x
z.string().refine(...)20.5kb4.4kb4.7x
z.string().transform(...)17.5kb2.0kb8.5x
z.string().pipe(...)21.4kb2.2kb9.6x
z.array(z.string())11.2kb1.9kb5.8x
z.tuple([...]), 3 items15.7kb2.8kb5.7x
z.record(...)16.4kb2.6kb6.2x
z.object({})6.6kb3.3kb2.0x
z.object({...}), 3 keys22.2kb5.3kb4.2x
z.object({...}), 10 keys82.0kb11.0kb7.5x
z.union([...]), 2 options17.5kb2.1kb8.3x
z.discriminatedUnion(...), 2 options42.0kb12.7kb3.3x
z.lazy(...)5.9kb1.3kb4.5x
zod/mini z.string()2.5kb577b4.4x
zod/mini z.object({...}), 3 keys11.8kb3.2kb3.7x

Own properties and V8

The problem with the old approach is exacerbated by a detail of V8's memory allocation around an object's own properties.

  • With fewer than 13 own properties, V8 tracks them in a compact 128-byte backing store
  • With 13 or more, V8 bumps the store up to 848 bytes
  • With 21 or more, it steps again to 1616 bytes

A regular string schema in [email protected] carried 49 own properties (40 methods and 9 properties) so every instance got the 1616-byte backing store.

// [email protected]
const s = z.string();
Object.getOwnPropertyNames(s).length; // 49
Object.getOwnPropertyNames(s).filter((k) => typeof s[k] === "function").length; // 40

Under the new system there are only six eagerly bound properties: _zod, def, type, format, minLength, and maxLength. Everything else lives on the memoizing prototype.

// [email protected]
const s = z.string();
Object.getOwnPropertyNames(s).length; // 6
Object.getOwnPropertyNames(Object.getPrototypeOf(s)).length; // 86

Measuring this

Retained bytes can be tricky to measure. The numbers above come from packages/bench/memory/schema-footprint.ts in the repo.

Upgrade to Zod 4.5 for these enhancements.

npm upgrade zod@latest