Skip to content

Reduce SchemaBinary bundle size - #7377

Merged
tim-smart merged 1 commit into
agent/codex-engineer/58cacc24from
agent/claude-engineer/01a0209a
Aug 20, 2026
Merged

Reduce SchemaBinary bundle size#7377
tim-smart merged 1 commit into
agent/codex-engineer/58cacc24from
agent/claude-engineer/01a0209a

Conversation

@tim-smart

@tim-smart tim-smart commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Bundle-size pass over effect/unstable/encoding/SchemaBinary, on top of merged #7371.

Closes EFF-784

Result

One byte-level defect, and evidence that the module itself is already close to its practical minimum.

EMPTY_READER_VIEW was built as new DataView(EMPTY_READER_BUFFER.buffer). The build annotates that with @__PURE__, but a member access in argument position is not effect-free to a bundler, so Rollup could not honour the annotation and kept the pair alive. The consequence was module-scoped: anyone importing effect/unstable/encoding for Ndjson, Msgpack, Toml or anything else shipped a fragment of SchemaBinary even though they never referenced it.

Both placeholders now read one named ArrayBuffer, so EMPTY_READER_VIEW.buffer === EMPTY_READER_BUFFER.buffer holds exactly as before and Reader.reset's buffer-identity check is unchanged.

Verified by bundling with preserveModules: the retained chunk in a barrel consumer went from

const EMPTY_READER_BUFFER=new Uint8Array(0);new DataView(EMPTY_READER_BUFFER.buffer);

to nothing at all. SchemaBinary now contributes exactly zero bytes to a barrel consumer that does not use it.

The trade is honest in both directions and small: a barrel consumer that does not use the module saves 31 minified / 19 gzip bytes, and a consumer that does use it pays 16 minified / 7 gzip bytes for the extra const.

The second change is a tracked fixture, packages/tools/bundle/fixtures/schema-binary.ts, so the module's size is compared on every PR by the existing Bundle job. It deep-imports effect/unstable/encoding/SchemaBinary rather than the barrel on purpose: through the barrel roughly a quarter of the bundle is msgpackr, and the fixture would track that instead of this module.

Measurement method

Rollup with the repository's own plugin pipeline (packages/tools/bundle/src/Plugins.ts: local dist resolution, NODE_ENV=production, esbuild node20, terser compress + mangle), then gzip -9 and brotli -11 over the emitted chunk. That is what pnpm bundle-compare measures, with exact byte counts instead of rounded kB. Composition is attributed with visualize-selected's raw-data.json, which reports unmangled rendered bytes per module.

Base is 418f9851, the merge commit of #7371.

What SchemaBinary costs

Every fixture is the same three-field struct, so the numbers subtract cleanly.

entry point min gzip brotli
Schema.toCodecJson baseline 58,815 19,015 17,376
SchemaBinary.toCodec 107,806 33,973 30,588
SchemaBinary.parser 96,917 30,663 27,720
both 112,388 35,368 31,821
SchemaBinary.fieldId alone 1,928 886 781

toCodec costs 48,991 min / 14,958 gzip / 13,212 brotli over a schema that was already there. Where that goes, in unmangled rendered bytes against the same baseline:

bytes share
SchemaBinary.js 71,826 70%
internal/dateTime.js 11,971 12%
Duration.js 7,803 8%
Encoding.js 4,027 4%
BigDecimal.js 4,001 4%
internal/result.js, Cause, Chunk, HashMap, HashSet, Redacted, rest 2,220 2%

The DateTime, Duration and BigDecimal weight is the leaf kinds, not overhead: DateTime.makeZonedUnsafe alone bundles to 18,345 min / 6,423 gzip in isolation, and the format has a dateTimeZoned kind.

isCyclic referencing Chunk, HashMap, HashSet and Redacted was raised as a bundle question when #7371 landed. Measured, it is a non-issue: those four cost 768 rendered bytes between them, because only the isX guards are reached and each shakes down to a prototype check.

Tree shaking

  • fieldId alone retains fieldId and nothing else. Export-level shaking inside the module is exact.
  • toCodec and parser do not drag each other in: 15,471 min bytes are codec-only, 4,582 parser-only, 92,335 shared.
  • No dead code. Every Writer and Reader method has a caller, and no module-level binding is unreferenced.
  • Every other top-level initializer already shakes: new TextEncoder(), new TextDecoder(...), makeOutputArena(...), new Writer(), new Reader(), Symbol.for(...) and the BigInt constants all disappear when unused. EMPTY_READER_VIEW was the only one whose argument defeated the annotation.

What is not worth changing

  • Message text. Blanking all 76 expected and Error literals in the built module saves 1,376 min / 376 gzip. That is the ceiling for deleting the entire error surface, so deduplicating the repeats is worth well under 200 bytes. Not worth touching code that is the documented error contract.
  • The K and F tables. Inlining them would save a few hundred bytes and would delete the two tables that state the wire kinds and the fingerprint tags in one readable place.
  • Fingerprint mode. Stubbing layoutFingerprint, both positional struct paths, decodeUnionPositional and sentinelSetHash saves 4,569 min / 1,365 gzip, about 4% of the bundle. { fingerprint: true } is a runtime option, so making that conditional means splitting the entry point, which is a public API change and out of scope here.

The barrel costs more than anything in this module

Worth recording separately, because it is four times larger than every in-module opportunity combined and none of it is SchemaBinary's code.

import min gzip brotli
from "effect/unstable/encoding/SchemaBinary" 107,806 33,973 30,588
from "effect/unstable/encoding" 136,431 43,965 39,241

The 28,625 min / 9,992 gzip difference is entirely msgpackr's pack.js and unpack.js, pulled in because the barrel re-exports Msgpack, which imports msgpackr at module scope. msgpackr declares no "sideEffects" and its top-level code is genuinely effectful (try { decoder = new TextDecoder() } catch {}, let defaultPackr = new Packr(...)), so no bundler can drop it once the module is in the graph. Every consumer of the barrel pays it, whichever encoding module they actually wanted.

Nothing here can fix that without changing when Msgpack loads msgpackr, which is a different module's public behaviour. Flagging it rather than fixing it. Note that SchemaBinary's own JSDoc examples use the barrel form.

Verification

Behaviour. Differential harness against the base source: 33 schema/value pairs in both wire modes covering every leaf kind, optional and full struct presence, tuples with null and packed slots, records with index signatures, unions, Option/Result/Exit, suspended recursion and JSON; each decoded at every truncation offset and with a surplus trailing byte; plus parser byte-at-a-time and bulk feeds, leftover bytes, maxFrameSize, a garbage envelope in both modes, fingerprint mismatch, missing required keys at errors: "first" and "all", and cycle detection. 1,044 lines of hex, decoded values, issue text and issue paths. Same md5sum as base, not merely diff-clean.

Benchmarks. benchmark/schema/SchemaBinary.ts, four interleaved base/head pairs, 78 SchemaBinary throughput rows: median -0.10%, mean +0.11%, range -2.08% to +2.35%, symmetric around zero. Every payload size row is identical at 0.00%.

Checks. pnpm lint clean, tsc -b tsconfig.json clean apart from the pre-existing NodeWorker.ts warning, 117 encoding tests, 8,710 effect tests.

Also folded in the trailing comment fix left over from the #7371 review: the note above missingKeyIssue still described the old accumulator.

`EMPTY_READER_VIEW` was built from `EMPTY_READER_BUFFER.buffer`. A member
access in argument position is not effect-free to a bundler, so the
`@__PURE__` annotation the build adds could not be honoured and the pair
survived tree shaking in every consumer that imports
`effect/unstable/encoding` without ever touching this module. Both
placeholders now read one named `ArrayBuffer`, which keeps
`EMPTY_READER_VIEW.buffer === EMPTY_READER_BUFFER.buffer` exactly as
before.

Add a tracked bundle fixture so the module's size is compared on every
PR by the existing Bundle job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ddf65fc

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@effect-slopcop effect-slopcop Bot added the bug Something isn't working label Aug 20, 2026
@tim-smart
tim-smart merged commit ddf65fc into agent/codex-engineer/58cacc24 Aug 20, 2026
@tim-smart
tim-smart deleted the agent/claude-engineer/01a0209a branch August 20, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant