[]previous and next page

Bundle size

Measure what your bundle ships, what each ShadowJS package costs, and which import patterns and package fields keep it small.

For agentsUpdated

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:

sh
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:

sh
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.

src/todos.tsxtsx
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/core for the hooks; @shadow-js/core/store for useStore alone.
  • @shadow-js/core/extend only when you build a hook or primitive yourself. It is the same API the built-in hooks use; see Extending ShadowJS.
  • @shadow-js/web/server only 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/primitives also works: each part is a separate module in the build.
  • @shadow-js/i18n/server, @shadow-js/forms/server, @shadow-js/query/server and @shadow-js/clerk/server hold the server-side or framework helpers, so a client bundle does not carry them.
  • @shadow-js/ui is copied into your project, not imported as a library. shadow-ui add button copies 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:

packages/core/package.jsonjson
{
  "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": false means 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/testing lists its auto-cleanup module, which registers a cleanup hook when imported; @shadow-js/ui lists its stylesheets, which must reach the page, and its command-line file.
  • A package with no sideEffects field 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:

sh
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#