Skip to content

Zod schema allocation dominates circuit-JSON generation heap (Zod v3 eager-bind + non-memoized get config()) #2810

Description

@itsbalamurali

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

  1. 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.

  2. 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)

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions