Skip to content

Bundle size audit: pay-for-use tree-shaking for @solidjs/signals subsystems #2883

Description

@ryansolid

Background

Solid 2.0's library size has grown substantially over 1.x, driven almost entirely by @solidjs/signals absorbing the async model (transitions, optimistic lanes, pending sources/status rails, boundaries, projections, stores). Current measurements (all uniform: esbuild bundle + minify + gzip, production defines, Jul 2026):

Full runtime (whole packages):

Minified Gzip
Solid 1.9.14 (core + store + web) 47.4 KB ~18.1 KB
Solid 2.0 beta (signals + solid + web) 89.5 KB ~33.5 KB
Svelte 5.56 (full client runtime) 102.6 KB 36.8 KB
React 19.2 + ReactDOM client 193.4 KB 60.3 KB

Tree-shaken minimal app (render + one signal):

Minified Gzip
Solid 1.x 10.6 KB 4.2 KB
Solid 2.0 31.6 KB 12.6 KB
Svelte 5 (mount) 39.7 KB 14.9 KB
React (no shaking) 193.4 KB 60.3 KB

Excluding the web runtime, the library proper is ~2.2–2.5x its 1.x size. We compare favorably against Svelte 5 and React today, but the minimal-app floor (12.6 KB gzip) means a hello-world app loads most of the async machinery whether it uses it or not.

Root cause of the high floor

  1. dist/prod.js is one flat rollup bundle. Once an app imports anything, all top-level side effects in the file are retained — including the GlobalQueue._* hook installs that wire subsystems together. Module-level tree-shaking can't separate subsystems that live in the same output chunk.
  2. Direct cross-subsystem calls. Some feature machinery is invoked directly from hot core paths (e.g. store proxy traps), making it reachable from createStore even when the feature is unused.

Proposed direction

  • Split the published dist into chunks (rollup code splitting; index re-exports from feature chunks). All packages already declare sideEffects: false, so bundlers will drop unused chunks. Needs care around the dev/prod/node output triplet and __DEV__ replacement.
  • Convert remaining direct couplings to the existing nullable-hook pattern (GlobalQueue._propagateAffects, _settleAffects, _clearOptimisticStore already demonstrate it), installed by the feature module on import.
  • Add a bundle-fixture test asserting the minimal entry's size/import graph, so shaking doesn't regress silently.

Candidates (rough order of value)

  • affects() — seams already in place (GlobalQueue._propagateAffects/_settleAffects); remaining work is hook-converting witnessAffectsMark (get/has traps, snapshotImpl) and inheritAffectsMarks (getNode), plus the chunk split. ~1.1–1.2 KB gzip recoverable of its ~1.3 KB cost.
  • Optimistic stores / projections — same shape of problem, likely larger recovery.
  • Transitions — biggest subsystem; feasibility unclear (deeply entangled with the scheduler), needs the audit to size it.
  • Subsystem byte-attribution pass on the signals bundle to find anything else worth detaching.

Non-goals

Returning to the 1.x floor (4.2 KB) — the async model is load-bearing. A realistic target is a ~7–8 KB gzip minimal floor while full-featured apps pay today's price.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions