Bundle size
Measure what your bundle ships, what each ShadowJS package costs, and which import patterns and package fields keep it small.
After this page you can measure the JavaScript your app ships, read what each ShadowJS package costs, import only what a page uses, and check that a package keeps its tree shaking. Sizes are gzip bytes of minified code, the same measure the release gate uses.
What a page costs#
These figures come from the size gate (npm run gate:size) at the time of writing. Each scenario is one
small app in tools/gates/scenarios, bundled with the production Vite setup of the repository. The gate
fails when a scenario grows more than 3% past its recorded budget in tools/gates/size-budget.json; the
budgets are the authoritative ceilings, and these rounded figures are for orientation.
The gate builds a scenario's workspace packages first when their dist is missing or older than their src, so
npm run gate:size runs from a clean checkout (--no-build measures the existing dist folders as they are).
It removes the //#region <path> comments that the bundler keeps before gzip: their text depends on where the
checkout lives, so counting them would make the budgets machine dependent.
| Scenario | Gzip | What it contains |
|---|---|---|
signal-only |
4.0 KB | a signal and a memo, no DOM |
hello-signal |
6.7 KB | one signal rendered into the page |
store-only |
6.4 KB | useStore and a rendered value |
show-only, suspense-only, for-only |
7.6, 7.3, 8.4 KB | one control-flow component each |
transitions-not-used |
7.0 KB | the counter without transitions |
transitions-used |
8.8 KB | the same counter with useTransition |
dusk-client-minimal |
8.6 KB | hydrateIslands from @shadow-js/dusk/client (its budget is above the 2026-10-09 figure, see the research notes) |
query-minimal |
12.9 KB | one useQuery with its own client |
i18n-minimal |
20.2 KB | one locale and one message through createI18n |
primitives-dialog-only |
21.3 KB | one dialog imported from @shadow-js/primitives/dialog |
ui-button-only |
22.5 KB | one copied Button and its helper |
router-minimal |
21.8 KB | one route through createRouter |
forms-minimal |
29.9 KB | one field in a useForm form (the opt-in features are not in it) |
Read the table as the cost of a feature on top of the framework. A transition costs about 1.8 KB, and a
page that never calls useTransition ships none of it.
Measure your own bundle#
Build the app, then sum the gzip size of the files the browser loads:
npx vite build
find dist/assets -name "*.js" -exec gzip -9 -c {} \; | wc -c
To see which modules a chunk contains, use a source map analyzer that reads the chunk's .map file.
source-map-explorer is one; it is not a dependency of ShadowJS, so run it with npx:
npx vite build --sourcemap
npx source-map-explorer "dist/assets/*.js"
Look for the modules of a package you did not expect. Each row is a source file, so a packages/web/src/...
row is the renderer and a node_modules/... row is a dependency of your app.
Import patterns#
Import each name from the module that exports it, with a named import. A bundler removes the names a page does not use only when it can see every use statically.
import { useStore } from "@shadow-js/core/store";
import { For } from "@shadow-js/web";
export function Todos() {
const [todos] = useStore([{ id: 1, text: "ship", done: false }]);
return (
<ul>
<For each={todos} key={(todo) => todo.id}>
{(todo) => <li>{todo().text}</li>}
</For>
</ul>
);
}
Choose the subpath by what the code does:
@shadow-js/corefor the hooks;@shadow-js/core/storeforuseStorealone.@shadow-js/core/extendonly when you build a hook or primitive yourself. It is the same API the built-in hooks use; see Extending ShadowJS.@shadow-js/web/serveronly in code that runs on the server. Client code never imports it.@shadow-js/primitives/<part>for one component family, for example@shadow-js/primitives/dialog. The root@shadow-js/primitivesalso works: each part is a separate module in the build.@shadow-js/i18n/server,@shadow-js/forms/server,@shadow-js/query/serverand@shadow-js/clerk/serverhold the server-side or framework helpers, so a client bundle does not carry them.@shadow-js/uiis copied into your project, not imported as a library.shadow-ui add buttoncopies the component you ask for, so only that component and the primitives it uses reach the bundle.
Avoid:
- Namespace imports (
import * as core from "@shadow-js/core"). Use the names you need. - Barrel files of your own that re-export a whole package, when the barrel has code that runs on import.
- Importing a page or feature for one constant. Move the constant into a small module.
Split what most visitors never open with lazy, a route, or a dynamic import(). See
Performance.
Package fields that enable tree shaking#
A bundler drops a module that nothing uses only when the package says the module has no side effects. Each
published package of ShadowJS declares that in its package.json. An excerpt of packages/core/package.json:
{
"name": "@shadow-js/core",
"sideEffects": false,
"exports": {
".": { "development": "./dist/dev/index.js", "default": "./dist/prod/index.js" },
"./store": { "development": "./dist/dev/store/index.js", "default": "./dist/prod/store/index.js" },
"./extend": { "development": "./dist/dev/extend/index.js", "default": "./dist/prod/extend/index.js" }
}
}
"sideEffects": falsemeans importing a module for a binding it does not use can be dropped.- A list of files means only those files have side effects.
@shadow-js/testinglists itsauto-cleanupmodule, which registers a cleanup hook when imported;@shadow-js/uilists its stylesheets, which must reach the page, and its command-line file. - A package with no
sideEffectsfield is treated as having side effects everywhere. Bundlers then keep everything it reaches.
Use the same rule for your own library: set "sideEffects": false, or list the files with real effects
(stylesheets, global registrations, a command that runs on import). The release gate checks every
ShadowJS package this way: an import of the package alone must cost at most 120 bytes, unless its file is listed as having side effects.
Development and production builds#
Each package maps its exports to two builds. The development condition selects a build with the
checks and messages; the default selects the production build. Bundlers use the development build when
the dev server runs and the production build for vite build. The browser and node/worker
conditions select the browser build or the server build of the packages that have both.
The production build contains no development code. ShadowJS replaces its internal __DEV__ flag at build
time, so the checks compile out. The release gate fails if a production bundle still contains __DEV__,
__TEST__ or __SERVER__. In your own code, use Vite's import.meta.env.DEV for development-only branches;
Vite replaces it with false in the production build.
Keep the numbers honest#
The release gate measures every public export and records it in tools/gates/export-sizes.json, with a
report in docs/research/export-sizes.md. Run it from the repository root after npm run build:
npm run gate:treeshake
node tools/gates/treeshake.mjs --explain "@shadow-js/web#render"
--explain prints the source files one export retains and where the export is defined. The gate
checks that an export brings only code from its own defining module's import closure, that every
package declares sideEffects, and that no package imports another's private files. A change that makes
an export grow by more than 3% or 40 bytes fails the gate until the table is regenerated with
--update in a reviewed change.
See also#
- Performance for updates that stay proportional to what changed.
- Code splitting in Performance for
lazyand routes. - Extending ShadowJS for building hooks from
@shadow-js/core/extend.