Summary
Circuit-JSON generation retains a very large number of Zod schema instances and
their eagerly-bound methods, dominating the heap on mid-size boards. Full
evidence, heap snapshot and retainer analysis are in
tscircuit/tscircuit#4077 — this issue tracks the fix, which is in
@tscircuit/core.
Why this repo
@tscircuit/core is where both halves live:
- it depends on
zod ^3.25.67 (Zod v3 eager-binds ~15 methods per schema
instance in the ZodType constructor), and
- it defines every component's
get config(), where the base
PrimitiveComponent returns a freshly-constructed z.object({}) per access
rather than a memoized/shared schema.
@tscircuit/props has no direct zod dependency and no get config().
The two code-level causes
-
Zod v3 per-instance method binding. Each schema instance carries ~15
bound closures (.parse, .safeParse, .parseAsync, .refine,
.superRefine, .optional, .nullable, .array, …). The snapshot shows
~714k native_bind nodes retained via .bound_this across ~47k instances.
-
get config() rebuilds schemas. Net/Trace return module-level
consts (zodProps: netProps, zodProps: traceProps), but the base
get config() returns a fresh z.object({}), and config is a plain
getter with no memo cache. The same schema shape is reconstructed thousands
of times (~9,952 identical ZodOptional instances in one build).
Suggested fix (pure-TS)
-
Migrate to Zod 4 (https://zod.dev/v4). v4 removes the eager per-instance
method binding, which eliminates the ~714k native_bind closures wholesale,
and documents large per-schema memory reductions. Biggest, lowest-risk win.
-
Memoize get config() / hoist schemas. Compute each component's
zodProps once (static per class or module const) instead of returning a new
z.object({}) from a getter. The base PrimitiveComponent inline
z.object({}) is the clearest offender.
-
Reuse parsed props. _parsedProps is read widely; verify props are
validated once and the parsed result reused rather than re-running
.parse/.optional() per element.
Verification
Measured transpiler-independent (same wall under tsx/SWC/oxc), and reproduced
with --routing-disabled (not the autorouter). See tscircuit/tscircuit#4077.
@tscircuit/core 0.0.1493, zod 3.25.76, Node 26.
Summary
Circuit-JSON generation retains a very large number of Zod schema instances and
their eagerly-bound methods, dominating the heap on mid-size boards. Full
evidence, heap snapshot and retainer analysis are in
tscircuit/tscircuit#4077 — this issue tracks the fix, which is in
@tscircuit/core.Why this repo
@tscircuit/coreis where both halves live:zod ^3.25.67(Zod v3 eager-binds ~15 methods per schemainstance in the
ZodTypeconstructor), andget config(), where the basePrimitiveComponentreturns a freshly-constructedz.object({})per accessrather than a memoized/shared schema.
@tscircuit/propshas no directzoddependency and noget config().The two code-level causes
Zod v3 per-instance method binding. Each schema instance carries ~15
bound closures (
.parse,.safeParse,.parseAsync,.refine,.superRefine,.optional,.nullable,.array, …). The snapshot shows~714k
native_bindnodes retained via.bound_thisacross ~47k instances.get config()rebuilds schemas.Net/Tracereturn module-levelconsts (
zodProps: netProps,zodProps: traceProps), but the baseget config()returns a freshz.object({}), andconfigis a plaingetter with no memo cache. The same schema shape is reconstructed thousands
of times (~9,952 identical
ZodOptionalinstances in one build).Suggested fix (pure-TS)
Migrate to Zod 4 (https://zod.dev/v4). v4 removes the eager per-instance
method binding, which eliminates the ~714k
native_bindclosures wholesale,and documents large per-schema memory reductions. Biggest, lowest-risk win.
Memoize
get config()/ hoist schemas. Compute each component'szodPropsonce (static per class or module const) instead of returning a newz.object({})from a getter. The basePrimitiveComponentinlinez.object({})is the clearest offender.Reuse parsed props.
_parsedPropsis read widely; verify props arevalidated once and the parsed result reused rather than re-running
.parse/.optional()per element.Verification
Measured transpiler-independent (same wall under tsx/SWC/oxc), and reproduced
with
--routing-disabled(not the autorouter). See tscircuit/tscircuit#4077.@tscircuit/core0.0.1493,zod3.25.76, Node 26.