Bundle size is not a number you optimise at the end. By the time a library is installed, the decisions that determine how much of it ends up in a browser have already been made, and most of them were made in the first week: how the package is entered, where the client boundary sits, and whether styling needs JavaScript to exist. A library that got those three right is small by construction, and one that got them wrong cannot be fixed by minification.
What tree-shakeable actually requires
Dead code elimination is an inference, not a promise. A bundler can drop an export only if it can prove nothing reachable depends on it, which requires static ES module syntax it can analyse, imports that are not resolved dynamically at runtime, and a module graph where reading one thing does not run another. Break any of those and the bundler does the safe thing, which is to keep everything, silently.
Side effects are the usual cause. A module that registers something on import, patches a prototype, injects a stylesheet or writes to a global cannot be removed even if none of its exports are used, because removing it would change behaviour. The sideEffects field in package.json is how a package tells the bundler that its modules are inert and safe to drop, and it is worth treating as a claim that has to stay true rather than a flag you set once.
Barrel files are where this usually goes wrong
A single index that re-exports the whole library is the friendliest thing to import from and the easiest way to destroy the property above. It is harmless when the graph beneath it is genuinely clean. The moment one module reachable through that barrel has a side effect the bundler cannot rule out, importing one component from it pulls in the neighbourhood, and the symptom is a bundle that grows when you add a single import that should have cost a kilobyte.
The build-time cost is real too: every barrel import asks the bundler to parse and analyse the entire surface of the package to work out that you wanted one function from it.
The client boundary is a bundle boundary
React Server Components change the arithmetic. A server component renders on the server and ships no JavaScript for itself at all; a client component ships, and so does everything it imports. The directive that marks the boundary is inherited downward, which means a library that marks its entry point as client-only has quietly declared that every component in it is client code, whatever any individual component actually needs.
Being RSC-safe means drawing that boundary per component and as tightly as the component allows: the interactive part is a client component, the structure around it is not. It also means no component touching browser APIs at module scope, because a module that reads from the window object while it is being imported cannot be rendered on a server at all, and the failure arrives as a build error in someone else's application.
Where a client component is genuinely heavy: a rich text editor, a charting surface, a date picker carrying a locale table. That boundary is also the natural place to split. Loading it on demand keeps its weight out of the first response for every screen that renders it below the fold or behind an interaction, and because the server component around it does not move, the split costs nothing structurally.
Zero runtime CSS keeps JavaScript out of styling
Styling systems that compute CSS while rendering put a style engine in the client bundle and a slice of work in front of every render, in exchange for authoring ergonomics that static CSS and custom properties now provide anyway. Shipping the styles as CSS and letting custom properties carry the theme keeps both costs off the page: nothing has to be evaluated for a component to know what colour it is.
The theme is still dynamic in the way that matters. Changing a declaration at a scope re-resolves every variable beneath it, which is a repaint rather than a re-render, and it is the browser doing work it is already extremely good at.
Measure what you actually ship
The only number worth quoting is the gzipped size of what a realistic screen pulls into a client bundle, measured through the application's own build. The size of the published package says nothing, and a per-file report says almost nothing, because the interesting failure is one accidental import dragging a whole subtree across the boundary. Orvain's core is being built to a budget of 12 kB gzipped, and a Button to 1.1 kB. Until the library ships those are targets, not measurements; the figures that will count are the ones taken through a real application's build on release day, and every release after it, the same way every time.
A budget is real when something fails on the day it is exceeded. Until then it is an aspiration, and aspirations lose to deadlines, which is how libraries end up large without anyone having decided that they should be.
The Orvain UI team
Writing about design systems and the tools that ship them.




