Skip to content

[9.4] fix(zod): reduce per-schema heap cost via shared method references (#263121)#265044

Merged
kibanamachine merged 1 commit intoelastic:9.4from
kibanamachine:backport/9.4/pr-263121
Apr 22, 2026
Merged

[9.4] fix(zod): reduce per-schema heap cost via shared method references (#263121)#265044
kibanamachine merged 1 commit intoelastic:9.4from
kibanamachine:backport/9.4/pr-263121

Conversation

@kibanamachine
Copy link
Copy Markdown
Contributor

Backport

This will backport the following commits from main to 9.4:

Questions ?

Please refer to the Backport tool documentation

…lastic#263121)

## Summary

Applies a memory optimization patch to Zod v4 to address significant
heap cost regression introduced with the upgrade to Zod 4.x (see
upstream issue [elastic#5760](colinhacks/zod#5760)).

### Problem

Zod v4 assigns every method (`parse`, `check`, `optional`, `email`,
etc.) as an **own-property arrow function** on each schema instance.
Because each arrow function closes over `inst`, each instance allocates
~91 unique function objects. V8 switches from fast-property mode to slow
"dictionary mode" when an object accumulates more than ~27 own
properties, causing an extra heap tax on top of the closure overhead. In
practice this costs **~12.8 KB of heap per schema** (vs ~1.5 KB with Zod
3).

### Fix — Shadow-Proto Architecture

Introduces a hidden intermediate prototype layer (`internalProto`)
between `_.prototype` (the user-visible class prototype) and the parent.
Library methods are placed on `internalProto` via `_initProto` (once per
concrete type, lazily), so they become **inherited** rather than own
properties.

```
instance → _.prototype (user space, empty by default)
         → internalProto (library space: 69 shared methods)
         → Parent
```

Key properties:
- **Own-property count**: ~91 → **~22** (well below V8's dictionary-mode
threshold of ~27)
- **Shared methods**: all 69 builder/parse methods are
prototype-inherited; instances share the same function objects
(`s1.optional === s2.optional`)
- **Backward-compatible**: user-added prototype extensions on
`_.prototype` still shadow `internalProto` transparently
- **Covers both variants**: classic and mini Zod APIs (6 files patched)

Six Zod files are patched (both CJS and ESM variants):

- `zod/v4/classic/schemas.cjs` / `schemas.js` — `_initProto` helper +
moves all builder methods to `internalProto`
- `zod/v4/mini/schemas.cjs` / `schemas.js` — same for the mini API
surface
- `zod/v4/core/core.cjs` / `core.js` — wires `_.prototype →
internalProto`, exposes `$internalProto` symbol; copy-loop left as no-op
for library methods

The patch is managed by `patch-package` and lives in
`patches/zod+4.3.6.patch`. An upstream PR is being prepared:
[colinhacks/zod#5870](colinhacks/zod#5870).

### Result

| Metric | Before (Zod v4, unpatched) | After (shadow-proto patch) |
| ------------------------------- | -------------------------- |
-------------------------- |
| Own properties per instance | ~91 | **~22** |
| V8 property storage mode | Dictionary (slow) | **Fast** |
| Heap cost per `z.string()` | ~12.8 KB | **~2.5 KB** |
| Shared method references | ✗ (per-instance closures) | **✓**
(prototype-inherited)|
| **Memory reduction** | — | **~80%** |
| `z.iso.datetime().optional()` | ✅ | ✅ |
| Prototype augmentation | ✅ | ✅ |
| All parse/validate/chain APIs | ✅ | ✅ |

> Validated by full Kibana heap snapshot comparison: ~113 MB reduction
at startup.

### Patch persistence

The patch is applied automatically during `yarn kbn bootstrap` (after
`yarn install`) via `patch-package --error-on-fail`. The `patches/`
directory is committed and version-controlled.

**Removal criteria**: remove `patches/zod+4.3.6.patch` (and the
`patch-package` devDependency) once the upstream Zod issue is resolved
and Kibana upgrades to the patched version.

### New dependency: `patch-package`

| | |
|---|---|
| **Purpose** | Applies and maintains the shadow-proto patch to
`node_modules/zod` across `yarn install` runs. Invoked as `patch-package
--error-on-fail` in the bootstrap step. |
| **Justification** | The optimization requires modifying Zod's compiled
JavaScript internals (`$constructor` wiring, `_initProto` helper,
builder method placement). These changes cannot be applied at the
TypeScript/import level, so a post-install patch is the correct
mechanism. `patch-package` is the industry-standard tool for this
pattern, with a simple invocation model and deterministic patch
application. |
| **Alternatives explored** | (1) **Custom patching script** — Kibana
used a bespoke `src/dev/node_modules_patches/` mechanism in an earlier
iteration of this PR; `patch-package` is strictly simpler and more
maintainable. (2) **`@kbn/zod` wrapper** — the wrapper re-exports
`zod/v4` but cannot intercept the compiled `$constructor` and prototype
wiring needed for this optimization. (3) **Private Elastic
fork/registry** — viable but significantly heavier: requires maintaining
a fork, publishing to a registry, and updating consumers;
disproportionate effort for a temporary vendor patch. |
| **Existing dependencies** | Kibana has no existing dependency
providing `patch-package`-equivalent functionality. `yarn patch` (a
built-in Yarn Berry feature) is not available since Kibana uses Yarn
Classic. |

### Test plan

- `npx patch-package --error-on-fail` applies cleanly from scratch
- `z.iso.datetime().optional()` works
- `z.string().email().optional()`, `obj.pick()`, `enum.extract()` all
work
- `z.httpUrl()` correctly rejects `ftp://`, `file:///` URLs (only
`http`/`https` accepted)
- Shared references confirmed: `z.string().optional ===
z.string().optional` → `true`
- Own-property count confirmed:
`Object.getOwnPropertyNames(z.string()).length` → `22`
- Memory benchmark: ~2.5 KB per `z.string()` (down from ~12.8 KB)
- `node scripts/check_changes.ts` passes
- Heap snapshot comparison: ~113 MB reduction at Kibana startup

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
(cherry picked from commit ae70b77)
@kibanamachine kibanamachine merged commit cdaf3a8 into elastic:9.4 Apr 22, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport This PR is a backport of another PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants