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) | ~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/mvcunpacks 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 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, 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.