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 import | gzip |
|---|---|
@expressive/mvc — Model only (headless) | ~7.0 kB |
@expressive/mvc — every export | ~10.9 kB |
@expressive/react — Model only | ~11.0 kB |
@expressive/react — Model, Component, instructions | ~11.9 kB |
@expressive/react — the above plus has and map collections | ~12.6 kB |
@expressive/react — every export | ~13.7 kB |
@expressive/mvc + @expressive/dom — renderer without style helpers | ~18.7 kB |
@expressive/mvc + @expressive/dom — every export, including styling | ~23.3 kB |
@expressive/router — every export | ~13.9 kB |
@expressive/react + @expressive/router | ~16.5 kB |
A typical React app lands around 11.9 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/mvcunpacks to ~340 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 ~7.0 kB against ~10.9 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 ~18.7 kB; adding style and Style raises the total to ~23.3 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: ~11.0 kB, reached by importing it at all. 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 JSXComponent.contextType, wiring the class into React contextisReactComponent, 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 and map are the clearest case: each declares itself renderable to the core as it loads, exactly as Component does, and the adapter adopts every declared class with the same element seam, so the patch that lets a pool or map render as an element travels with the collection module itself - an app that imports has pays for it (~0.7 kB together with map), an app that never does ships neither. pending and the rest of the core drop out the same way.
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.