Repository navigation
perf(lazy-zod): keep less per stand-in, so startup retains less heap - #1021
Conversation
…sures internalZ made two closures and their contexts per stand-in, which cost more than the stand-in itself. A stand-in now keeps zod's factory, its namespace and the arguments (or the parent and method it was chained from) and drops the arguments once built. The public z's stand-ins make one closure instead of two.
…s built A stand-in cleared its arguments before building, so a build that threw left it empty: the next parse built the schema without them (an internalZ.object whose build threw became z.object() and accepted any object). It now drops them after a successful build, as the closure was dropped before. A chained stand-in also kept its parent stand-in, and the parent's built schema, for as long as it lived. It now drops the parent once built.
|
@coderabbitai review |
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
|
@coderabbitai review |
Action not completed
|
Action not completed
|
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-03T08:01:05.591Z |
|
@coderabbitai review |
Action not completed
|
|
@coderabbitai review |
Action not completed
|
|
@coderabbitai review |
Action performed
|
… over #1016 On main 998ca63 with #1016, the lazy stand-ins add 457 bytes to create() + WebMcpPlugin + one tool (1,346,697 -> 1,347,154) and 437 to Vite's initial chunk (964,258 -> 964,695). Those budgets rise by exactly that: 1,346,921 -> 1,347,378 and 964,390 -> 964,827. create() + one tool (1,339,977) still fits 1,340,056.
Stacked on #1016
Refs #948
What and why
Follow-up to the 1.10.9 performance work (#914, #919 to #925, #932, #934 to #943). #948 asks that a FrontMCP server, after startup and the first
tools/call, have about as much new space committed as the official MCP SDK's server (16.8 MB, against FrontMCP's 33.6 MB), with RSS dropping accordingly and no cold-start or throughput regression, by cutting about 10 MB of live heap at startup.This PR measures where that young generation comes from, cuts what FrontMCP's own code wasted the most, and lists what is left, with numbers. The acceptance is not met: the new space is still 32 MiB. Reaching 16 MiB needs about 6 MB less young-allocated data alive at the first
tools/call(17.5 → about 11.5 MB). FrontMCP's own startup objects that could go are worth well under that. Most of the rest comes from dependencies and from the size of the SDK's single CommonJS file (below).Why V8 doubles the young generation
Reproduced on this base (the benchmark's FrontMCP server,
1a4d98a36):v8.getHeapSpaceStatistics()reportsnew_space33,554,432 bytes (32 MiB, two 16 MiB semi-spaces) at the firsttools/callanswer, and 16,777,216 (16 MiB) for the official SDK server, in every run.V8 starts with 1 MiB semi-spaces and doubles them when the bytes that survived young collections (copied or promoted, mark-compacts included) since the last doubling exceed the semi-space size.
--trace-gc-nvpof the base server through the first answer (MB = 10^6 bytes; "since growing" is the running total the rule compares, reset at each doubling):The 8 MiB doubling comes as
require('@frontmcp/sdk')reaches@frontmcp/auth. The 16 MiB doubling comes while the SDK loads@frontmcp/protocol, the MCP SDK's server and ajv: 9.00 MB had survived since the 8 MiB doubling, against a limit of 8.39 MB (8 MiB). The official server ends at 8 MiB, with 6.63 MB survived since its last doubling. For FrontMCP to stay at 8 MiB, everything that survives from the 8 MiB doubling to the first answer (19.4 MB here) would have to stay under 8.39 MB. A long-lived object is counted up to twice (copied, then promoted; 30.6 MB survive in all for 17.5 MB alive at the end), so that means about 6 MB less young-allocated data alive at the first answer.Where the live young heap comes from
Measured directly: with 64 MiB semi-spaces (
--min-semi-space-size=64 --max-semi-space-size=64) no scavenge runs before the first answer. The probe then forces one scavenge, and its survivors are exactly the young-allocated objects still alive. The number is the same in every run:tools/callanswerThe same method, with a scavenge forced at the end of each startup phase, gives what each phase allocated and still holds when it ends (base → this PR, MB):
require('@frontmcp/sdk')13.39 → 12.80, the@Tool/@App/@FrontMcpdecorators 0.87 → 0.87, scope setup up to listening 2.25 → 2.21, listening to the first answer 1.68 → 1.71. Loading the SDK and what it requires is most of it.An allocation-tracked heap snapshot of the base at the first answer attributes those objects to the code that allocated them. Only the kinds V8 allocates young are counted, so the split is approximate:
@frontmcp/sdk's CommonJS file (index.js, one-byte)—in a comment, makes V8 store the whole file at two bytes per character)@frontmcp/auth,utils,protocoland FrontMCP's other packagestypes.jsbuilds its schemas when it loads (1.06), and 1,087 of FrontMCP's lazy schemas are built before the first answer (about 1.2)@hono/node-serverand Node'shttp2,tlsand bundled undici, loaded after listeningThe 1,087 lazy schemas built at startup are FrontMCP's own metadata and option schemas, which their parse walks in full:
@FrontMcpmetadata 446,@App136,@Tool116, the@Flowdecorators about 150 (http:request91,tools:call-tool20), auth options 61, the session of the first request 60, the config plugin and service 62. zod 4's object parse normalizes every key of a shape,.optional()reads its inner schema'soptin, and defaults have to run, so a parse builds the whole tree whatever keys the input has.What this PR changes
lazy-zod: a stand-in keeps what it is built from in fields, not in closures.
internalZ, which every FrontMCP schema insdk,authandutilsuses since #1010/#1011, made two closures and two contexts for each stand-in: the factory wrapper increateLazyZand the build closure indeferEveryFactory(or the chained-method closure inchain). Together they cost more than the stand-in object and its Proxy. A stand-in is now aFactoryStandIn(zod's factory, its namespace and the arguments) or aChainedStandIn(the parent stand-in, the method and the arguments). All of them hand their base class the same static builder. Once it has built, a stand-in drops its arguments, and a chained stand-in its parent, as the closure was dropped before. A build that throws keeps them, so trying again builds the same schema. The publicz's compound stand-ins make one closure instead of two. Nothing else changes: the same factories build the same schemas from the same arguments, in the same order (a chained stand-in builds its parent first, then its arguments' children).createLazyZandStandInFactoryaren't exported from the package entry.Effect: −0.47 MB of young-allocated data alive at the first answer, and −0.42 MB of heap after a full GC over HTTP (−0.43 MB over stdio), in every round. In a microbenchmark (6 runs each), making 2,000 metadata-shaped object schemas of 20 stand-ins each takes 24.6 to 27.7 ms of CPU instead of 29.9 to 37.0 ms. Building and parsing them takes the same time either way (302 to 336 ms against 302 to 344 ms).
The tables under Results measure this PR's first commit. The second commit keeps a stand-in's arguments when its build throws, and drops a chained stand-in's parent once it has built. Dropping the parent takes a further 6 KB off the young data alive at the first answer (17.069 → 17.063 MB, three runs each) and 0.04 MB off the live heap after a full GC over HTTP (31.04 → 31.00 MB, 8 rounds, all lower). CPU time to the first answer over HTTP doesn't change: +3.1 ms per round (−19.3 to +19.5), against −0.7 ms (−15.6 to +11.1) between two identical copies of the first commit (14 rounds of 3, load 24 to 38).
Tried and dropped (measured, not shipped)
@FrontMcpmetadata schemas only for its one parse and dropping them afterwards: 17.08 → 17.56 MB (+0.48). The scope builds the same option schemas again for its own parses.--minify-identifiers --minify-syntax --keep-nameson the published file): the source shrinks from 2.17 to 1.57 MB, but live young data goes up to 18.50 MB.--keep-namesadds a__name()call for every class and function.What is left, with evidence
What remains is mostly outside the code this PR touches. Even all of FrontMCP's own startup objects together come to less than the 6 MB needed:
v4/core,v4/classic,v4/miniandv3, the MCP SDK's CommonJS build andqs(whatscripts/escape-non-ascii.mjsalready does to FrontMCP's own output) brings live young data from 17.54 to 16.68 MB (−0.86). The new space stays at 32 MiB. 44 of the 62 two-byte zod files are locales, which zod loads on every import (1.10: startup and first-request costs that come from dependencies #951, item 4).server/index.jsloads zod v3 (v3/types.cjs, 282 KB two-byte),zod/v4-mini,zod-to-json-schemaand ajv, andtypes.jsbuilds about 1.06 MB of zod schemas when it loads. The official server, on SDK v2, doesn't load these. FrontMCP'sServercomes fromserver/index.js, so the server can't run without them.http.Results
Apple M4 Pro (14 cores, 48 GiB), macOS 15.6.1, Node.js 24.22.1, on AC power, with other jobs running on the machine (1-minute load average given per run; it was 22 to 175 during this work). The server is the benchmark's FrontMCP implementation (
frontmcp build --target node), run withnode, againstnpm packed builds of1a4d98a36(this PR's base:mainat 91531eb plus #1016) and of this branch, each installed into a scratch copy of the implementation and built withnpm run build. The twodist/node/bench.bundle.jsare byte-identical, and only@frontmcp/lazy-zoddiffers between the package sets. The official SDK server is the benchmark'sofficial-sdkimplementation. Runs use the benchmark's harness functions (startServer,HttpSession,connectStdio,coldStartOnce,throughputOnce,rssOfTree) in randomized, interleaved rounds. "Per round" is the median, over rounds, of this branch's value minus the base's in the same round, with its range. MB is 10^6 bytes.Memory, run A: 16 rounds, one start per build and transport (1-minute load average 101 to 124). Live heap: 12 rounds with
--expose-gc, a full GC right after the first answer (load 63 to 100).new_spacecommitted at the first answer, HTTP and stdiotools/calls and 1 s, HTTPtools/calls and 1 s, stdioRSS doesn't move measurably. The 0.4 MB of heap is below RSS's round-to-round spread, and the young generation, which RSS follows, is the same size. "Heap used" without a GC isn't a live-heap measure over stdio: with no mark-compact before the first stdio answer, it swings by 11 MB depending on whether a scavenge lands just before the answer.
Cold start and CPU, run B: 12 rounds of 3 starts per build and scenario (load 90 to 134, median 107). Run C: 16 rounds of 3 (load 74 to 142, median 123).
tools/calls and 1 s (12 rounds, load 104 to 124), this PR used −7.8 ms of CPU per round (−27.4 to +12.6, 8/12 lower): the work moves earlier, and none is added.Throughput: the benchmark's
throughputOnce, concurrency 1, 16 and 64, 2 s warm-up and 5 s measured per level, 10 rounds (load 22 to 111, median 49). No errors.A first run of 8 rounds (load 45 to 64) had −0.0%, −1.1% and −1.9% per round, with ranges of −71% to +672%. The change isn't on the per-call path: a stand-in's
parseis bound to its built schema on first use, as before. So these differences are load, not the change, and neither run shows a regression.User-facing change
@frontmcp/lazy-zod's publicz,lazyZ,LazyZodSchema,internalZand the schemas they build behave as before. A FrontMCP server at the firsttools/callholds about 0.42 MB less heap.demo-e2e-bundle-size, base build swapped in for the before column):create()+ one tool 1,322,622 → 1,323,079 bytes minified (+457; gzipped 346,320 → 346,489), withWebMcpPlugin1,329,811 → 1,330,268 (+457), Vite's initial chunk 943,585 → 944,020 (+435). All within the budgets of that base, so no budget changes then.mainat 998ca63 (measured against perf(sdk): set up jobs, workflows, channels, remote MCP, the ESM loader and OAuth flows on first use #1016's head dae7555):create()+ one tool 1,339,520 → 1,339,977 (+457), still within 1,340,056; withWebMcpPlugin1,346,697 → 1,347,154 (+457), so its budget rises by exactly that, 1,346,921 → 1,347,378; Vite's initial chunk 964,258 → 964,695 (+437), so its budget rises by exactly that, 964,390 → 964,827.nx run sdk:testpasses 9,257 tests in 660 suites,nx run lazy-zod:test369, and bundle-size 23.Tests
libs/lazy-zod/src/__tests__/internal-z.spec.ts, written first: two alikeinternalZstand-ins (a leaf, an object, a chained leaf, a chained union) hold the same functions, so no stand-in makes one of its own. This failed on the base, where each held its own closure. A built stand-in no longer holds the shape or options array it was made with. This one passed on the base too, where the closure that held them was dropped, and now guards the new fields. Two more pass on the base and failed on the first commit: a built chained stand-in no longer holds the stand-in it was chained on, and a stand-in whose build threw builds from the same arguments when tried again. On the first commit, the arguments were dropped before building, so the second parse of aninternalZ.objectwhose build had thrown builtz.object()and accepted any object. The existing parity suites (every factory and chain against Zod's own schemas, same issues and JSON Schema, leaf children built with their parent, compound children deferred, memoizer handling, introspection) pass unchanged.nx test lazy-zodpasses 369 (internal-z.tsandlazy-z.ts100% covered),utils2,025,auth2,811,di245 (1 todo),guard271, andnx run sdk:test(--maxWorkers=50%) 8,487 tests in 611 suites.nx run lazy-zod:lintreports 0 errors and no new warning (the 12 warnings are the existinganydefaults in thezandinternalZtype namespaces), andtsc --noEmitonlibs/lazy-zod/tsconfig.lib.jsonandtsconfig.spec.jsonreports no error.demo-e2e-bundle-size23 (sizes above),demo-e2e-dts-consumer22,demo-e2e-stdio-transport30,demo-e2e-standalone53. Standalone needsnx run observability:buildfirst: withoutlibs/observability/dist, its three observability tests fail with "@frontmcp/observability is not installed".node --test scripts/*.test.mjspasses 84, among them the Worker export-condition and browser build guards.tools/callcorrectly (the harness checks each result) in about 750 starts of each build over HTTP and stdio.node scripts/fix-unused-imports.mjschanges nothing, and Prettier passes on every changed file.Summary by CodeRabbit