Expressive MVC
Guides

Bundle Size

What Expressive costs your bundle, measured per import shape and gated in CI

Every figure on this page is produced by .github/scripts/size-limit.ts, which bundles the built packages and gzips the result. The same script checks every pull request against its budgets, and budgets and these figures are re-set together before each release, so these numbers cannot drift away from reality — growth is flagged where it happens and settled before it ships.

What it costs

Minified, then gzipped. React is a peer dependency and excluded, as it would already be in your app.

What you importgzip
@expressive/mvc — Model only (headless)~6.4 kB
@expressive/mvc — every export~10.1 kB
@expressive/react — Model only~10.3 kB
@expressive/react — Model, Component, instructions~11.2 kB
@expressive/react — every export~13.0 kB
@expressive/mvc + @expressive/dom — renderer without style helpers~17.8 kB
@expressive/mvc + @expressive/dom — every export, including styling~19.9 kB
@expressive/router — every export~13.2 kB
@expressive/react + @expressive/router~17.1 kB

A typical React app lands around 11.2 kB. There are no runtime dependencies, so that is the whole cost — nothing else arrives behind it.

Methodology, and why the numbers differ elsewhere

Bundle size has no single definition, and the three common ones differ by more than the numbers above:

  • gzip of an import shape — what this page reports, and what actually reaches your users.
  • Unminified and gzipped — larger, because comments survive; 18-42% of our shipped JavaScript is documentation comments, which minification removes. Reporting this number would overstate the cost roughly twofold.
  • npm install size — much larger again (@expressive/mvc unpacks to ~237 kB), because the published tarball carries type declarations and source maps. Those never enter a browser bundle.

If a third-party size service quotes something different from this table, the measurement is probably one of the other two.

Tree-shaking

The headless core shakes properly: importing only Model costs ~6.4 kB against ~10.1 kB for everything, so unused instructions genuinely do not ship. It is built unbundled — one module per source file, sideEffects: false — and its module-level work only touches classes the same module exports, which is what makes that declaration safe.

The appearance system is likewise optional: renderer imports measure ~17.8 kB; adding style and macro raises the total to ~19.9 kB. The renderer retains only the small token protocol needed to accept styles registered elsewhere. Unlike the React rows, these figures include @expressive/mvc, which @expressive/dom does not re-export and an app therefore imports directly.

The React adapter has a floor you cannot shake below: ~10.3 kB, reached by importing anything at all from it. That is deliberate and worth being plain about. The adapter works by patching types it does not own, at import time:

  • Component.prototype.$$typeof, so an instance can be placed directly as JSX
  • Component.contextType, wiring the class into React context
  • isReactComponent, so React treats it as a class component
  • the host runtime registry, which hands the core its renderer

Cross-package prototype patches cannot be tree-shaken away without breaking the library, so the wiring arrives whole or not at all. Above the floor, shaking resumes normally — has, map and pending together account for the remaining ~2.6 kB and drop out when unused.

The practical consequence: Expressive rewards using more of what you have already paid for, and is a poor fit for shipping one tiny piece of it into an otherwise dependency-free bundle.

Raising a budget

Budgets live in .github/scripts/size-limit.ts, one per import shape rather than a single aggregate — the point is to notice when the adapter floor grows, or when a formerly shakeable export stops shaking, neither of which a total would reveal. A pull request that grows a shape past its budget gets a failing size check and a table in its checks, but still merges, and leaves the budgets alone. Budgets are re-set once per release, in a separate pull request that also updates the table above — one deliberate decision per release instead of a nudge per feature.

On this page