Inside Storybook’s On-Demand Compilation

Last updated: May 09, 2026


Storybook’s “on-demand compilation” actually names two distinct engineering layers that the marketing collapses into one phrase. The first is on-demand architecture: a static CSF indexer that parses each story file’s AST in Node and exposes a story index the sidebar reads before Webpack starts. The second is lazy compilation: a Webpack-only build-time feature that wraps each per-CSF chunk in a proxy module and compiles it on first visit. Pulling the layers apart explains why CSF boots fast even without lazy compilation.

  • Static CSF indexing shipped as storyStoreV7 in Storybook 6.4 (December 2021), became default in 7.0, and the legacy V6 path was removed in 8.0.
  • Webpack lazy compilation shipped in Storybook 6.5 (May 2022) and remains an experimental Webpack feature, opt-in via core.builder.options.lazyCompilation.
  • The static indexer requires literal title strings and statically named exports; storiesOf and computed titles fail at index time, before Webpack runs.
  • Vite-builder Storybook skips the proxy layer because native ESM already loads each CSF on demand.
  • Lazy compilation trades cold-boot time for a perceptible pause on every newly visited story — a real DX cost on design-system browsing patterns.

Two features, one label

The query “storybook on demand compilation” gets searched by people trying to do two different things, and the SERP cannot tell them apart because the two top-ranking Storybook posts run them together. On-demand architecture changes how the story index is built and how the runtime loads stories: the index is computed statically in Node, the sidebar renders before any user code is bundled, and every CSF file becomes its own dynamic-import chunk. Lazy compilation changes when Webpack actually emits each of those chunks: instead of producing the full dev bundle on boot, Webpack injects proxy modules and only compiles the real code when the browser asks for it.

The difference matters because the levers behave differently. On-demand architecture is on by default in any modern Storybook and explains the bulk of the perceived “fast boot” experience even on a Webpack project. Lazy compilation is an extra opt-in that bites only when story count is large enough that compiling all CSF files at boot still hurts. Conflating the two sends people to flip a flag that does very little for their project size, and away from the architectural reason the sidebar appears so quickly.

I wrote about component-driven workflow if you want to dig deeper.

Topic diagram for Inside Storybook's On-Demand Compilation: How CSF Boots Faster Than Webpack
Purpose-built diagram for this article — Inside Storybook’s On-Demand Compilation: How CSF Boots Faster Than Webpack.

The diagram shows the boot sequence side by side: the indexer pass runs entirely in Node and produces routes plus a sidebar tree before the dev server hands control to the browser. Lazy compilation only enters once the user clicks a story and the browser issues its first dynamic-import request — it sits downstream of indexing, not as a replacement for it.

The static CSF indexer is why CSF boots faster than Webpack

The fast boot trick has nothing to do with Webpack. When you run storybook dev, the manager process globs the configured stories entries, AST-parses each CSF file with the official indexer, and exposes the generated story index over the dev server at /index.json. That document is the entire sidebar — every story id, title, name, and import path — and it ships to the browser before any user code is compiled. The browser can render the navigable tree, generate every route, and even highlight the active story while Webpack is still spinning up.

// Excerpt from the generated story index served at /index.json
{
  "v": 5,
  "entries": {
    "components-button--primary": {
      "type": "story",
      "id": "components-button--primary",
      "name": "Primary",
      "title": "Components/Button",
      "importPath": "./src/components/Button.stories.tsx",
      "tags": ["autodocs", "story"]
    },
    "components-button--secondary": {
      "type": "story",
      "id": "components-button--secondary",
      "name": "Secondary",
      "title": "Components/Button",
      "importPath": "./src/components/Button.stories.tsx"
    }
  }
}

Notice what is and is not in that document. There is enough metadata to build the entire UI shell — id, title, name, tags, importPath — and zero compiled JavaScript. The browser uses the index to construct the URL space and the tree before Webpack has finished compiling any chunk. That is the real on-demand mechanism: the sidebar is decoupled from the build, and the build is decoupled from sidebar order. Add or rename a story and the indexer republishes the index while the sidebar updates without rebuilding any user code.

Related: Storybook’s ESM shift.

Terminal output for Inside Storybook's On-Demand Compilation: How CSF Boots Faster Than Webpack
Here’s what the example produces.

The captured terminal trace confirms the sequence. The indexer ready message lands well before webpack compiled successfully, and the manager UI is interactive in between. This is the part of the boot that gets called “instant” — it is not Webpack getting faster, it is the sidebar being emitted by a separate Node-only pass that runs ahead of compilation.

Storybook 7 rewrote this pipeline behind the public experimental_indexers API, which lets community packages teach Storybook how to derive stories from non-CSF sources — Svelte SFCs, MDX, Vue templates — by returning a createIndex(fileName) result that conforms to the same JSON shape. The contract is documented in the official indexers reference, and the RFC discussion #23176 records why the older storyIndexers preset was replaced.

What the indexer contract forbids — and what the error looks like when you break it

The indexer is a static AST analysis, not a runtime evaluation. That single fact dictates everything you can and cannot do in a CSF file. Anything the indexer cannot resolve by reading the source — without executing it — is a contract violation that fails before Webpack runs.

The contract’s load-bearing constraints are concrete:

  • The default export must include a title that is either a string literal or omitted entirely. Computed titles are unsupported.
  • Story exports must be named identifiers — export const Primary = .... Dynamic names like export const [storyName] = ... cannot be indexed.
  • Stories must live in the file the index points at; re-exporting a story object from another module produces an entry the runtime cannot resolve.
  • The legacy storiesOf() imperative API is no longer indexable in Storybook 8 and above.
// This default export breaks the indexer
export default {
  title: `Components/${process.env.PRIMARY_BRAND}/Button`, // computed at runtime
  component: Button,
};

// stderr from `storybook dev`:
// Unable to index ./src/components/Button.stories.tsx
// CSF: unexpected dynamic title

Each violation surfaces a different stderr line. A computed title trips the AST validator and yields the message above, logged against the offending file. storiesOf usage in a Storybook 8+ project surfaces a hard error pointing readers at the migration RFC: the project’s deprecate storiesOf RFC is the canonical document, and the deprecation PR #23938 records the removal path. The fix is always the same shape: convert the dynamic CSF construct into a static one, or write a custom indexer that produces the equivalent JSON entries from a non-CSF source.

Official documentation for storybook on demand compilation
The primary source for this topic.

The screenshot of the indexer reference page doubles as a contract spec, but it pays to read it the way the docs do. IndexInput has a small required core — primarily type and the export identity the runtime needs to load — while importPath is optional and defaults to the file the indexer was handed, and most of the rest (title, name, tags, metaId, the optional __id) are either optional or filled in from sensible defaults when the indexer omits them. A custom indexer only has to produce the required core; everything else is there for the cases where you need to override what the default flow would already compute, not as a per-row shopping list to fill in for every story.

How Webpack’s lazy compilation actually fires on first visit

Lazy compilation is the second-stage feature, and it is a Webpack experiment, not a Storybook invention. Storybook’s role is to wire it through with the right options:

// .storybook/main.js
export default {
  framework: '@storybook/react-webpack5',
  core: {
    builder: {
      name: '@storybook/builder-webpack5',
      options: {
        lazyCompilation: true,
        fsCache: true,
      },
    },
  },
};

The mechanism, as documented on the Storybook lazy compilation post, is straightforward once you watch a network panel during a cold boot. Webpack emits a placeholder proxy module for every CSF chunk. When the browser dynamically imports a story, the proxy fetches the real chunk from a dev-server endpoint Webpack registered for lazy compilation requests. The dev server invalidates the proxy in its compiler instance, runs only the dependency graph rooted at that CSF file, emits the chunk, and serves it. Subsequent visits hit the warm chunk; subsequent edits invalidate only the affected CSF and its dependents.

Rolldown’s bundling story goes into the specifics of this.

Architecture diagram for Inside Storybook's On-Demand Compilation: How CSF Boots Faster Than Webpack
Walkthrough of the moving parts.

The architecture diagram traces the lifecycle: indexer pass publishes the story index, manager renders the sidebar, browser dynamic-imports a CSF chunk, the proxy module hits the dev-server middleware, and only then does Webpack compile that subgraph. The split between “indexer pass” and “Webpack pass” is the line the SERP refuses to draw — and it is also the line that explains why the sidebar feels instantaneous even before lazy compilation is enabled.

The first-visit latency tax nobody quotes

Cold-boot time is one number. First-visit latency is a different number, and lazy compilation makes the second one worse on purpose. Each new story click triggers a fresh compile of that file’s dependency graph, and on a real component the cost is rarely zero.

Public claims about “how fast” each configuration is should be read with a wide error bar. Storybook’s own lazy-compilation announcement describes large boot improvements — the figure most often cited from the team’s posts is “up to 3x” cold-boot reduction, which is meaningfully sized but a long way short of an order of magnitude — and the team has also published several boot-time comparisons against the Vite builder over the 7 → 9 line. The qualitative picture from those posts is more conditional than the loud headlines suggest: enabling lazy compilation visibly cuts cold boot on Webpack projects with hundreds of stories; the Vite builder generally wins HMR and warm-rebuild and often wins cold startup as well, though Storybook’s own published Webpack-to-Vite benchmark does not put Vite ahead in every cold/warm case across every fixture; warm-cache rebuilds are uniformly small numbers regardless of builder.

What none of those posts include is a reproducible benchmark with raw logs, locked package versions, and pinned hardware. That is also why this article does not publish its own number table: with no shared fixture repo, no raw timing dataset, and no way to fix the dependency graph against a public commit, any “p50/p95” claim is just a screenshot. If you need real numbers for a budget conversation, the only honest path is to run storybook dev against your own repo with telemetry off and time the four states yourself: cold boot, first uncompiled-story click, warm rebuild on edit, and rebuild on a story that imports a heavy dependency. The same measurement on the same machine across two builder configurations is more decision-useful than any third-party table.

The qualitative shape, which is borne out across most published comparisons, is what matters for choosing knobs. Lazy compilation reshapes the cost from “one big bill at boot” to “many small bills on every newly visited story.” Cold boot drops noticeably. First-visit time on an uncompiled story grows from “already there” to “a beat between click and paint.” Rebuilds get faster because the graph is smaller. The Vite builder is the most consistent configuration for winning on both ends of the curve at once, even if no single public benchmark puts it on top of every column.

Dashboard: Storybook CSF On-Demand Boot
Multi-metric dashboard — Storybook CSF On-Demand Boot.

The dashboard view of the same fixture makes the trade-off legible even without numbers. Lazy compilation is not free latency; it reshapes the latency curve. You pay less on cold boot and more on every cold story click — fine for a developer camped on one component, miserable for a designer browsing 200 stories during a design-system review.

Why Vite-builder Storybook skips this whole machinery

The Vite builder gets the boot win without any Webpack proxy because native ESM in the browser already serves modules on demand. Vite’s dev server compiles a CSF file when the browser requests it via an import() call; there is no equivalent of the proxy module because there is no Webpack to wrap. The Storybook indexer still runs the same way it does on the Webpack builder — that pass is builder-agnostic — but the second-stage lazy work is built into how Vite serves modules in dev.

That changes the recommendation. On a greenfield project, Vite-builder Storybook hands you the architecture cleanly: per-CSF laziness by default, no experimental flag, no Webpack-specific dev-mode quirks to reason about. The Webpack builder docs still document lazyCompilation as experimental and as a development-mode feature, and that label has not changed across the 7 → 8 → 9 → 10 line. If your team is not locked into Webpack-specific tooling, switching builders deletes the entire problem class.

Related: Vite’s module graph internals.

A decision rubric for which knob actually moves your boot time

The right knob depends on your story count and your builder lock-in. Lazy compilation is a development-mode Webpack feature — Storybook’s Webpack configuration docs describe it that way, and it does not flow into the static output that storybook build produces. Whatever shape the dev graph takes when lazy compilation is on, the production graph is built by a separate run that does not use it. The discipline that actually protects production parity is to run storybook build in CI and assert against the produced graph there, not to read the dev-mode shape and assume it predicts the static export.

Decision matrix — pick the knob that fits your project, not the one in the loudest blog post
Project shape Recommended config Stops being right when…
< 100 stories, Webpack-locked Webpack default — leave lazyCompilation off Cold boot crosses the patience threshold; the on-demand architecture alone is enough at this scale
100–500 stories, Webpack-locked Webpack + lazyCompilation + fsCache Designers complain about the click pause more than developers complain about boot
500+ stories, Webpack-locked Webpack + lazyCompilation + fsCache + SWC swap You can move to Vite — the SWC swap is a half-measure compared to dropping Webpack
Any size, builder choice open Vite builder You depend on a Webpack-only loader the Vite builder cannot replace
Static export to CDN with chunking SLA Whichever dev config wins your inner loop; verify the production graph by running storybook build in CI and asserting on the actual chunks it emits CI no longer catches the chunk-shape regression you actually care about — at which point the missing assertion is the bug, not the dev-mode flag

The SWC compiler swap is an orthogonal lever. It replaces Babel inside the existing Webpack pipeline and helps both default and lazy configurations, but it does not change the on-demand architecture story. If lazy compilation is off the table for any reason, swapping in SWC is a safe way to take a meaningful slice off cold-boot time without touching the dev/prod split at all.

For more on this, see why teams pick Vite.

Version reality check, Storybook 7 → 8 → 9 → 10

Most of the SERP for this query is still circa 2021–2022. Several things have actually moved since then. storyStoreV7 became the default in 7.0, and the V6 path — including storiesOf() — was removed in 8.0; the migration guide from 6.x to 8.0 records the breaking change. The indexer API was rewritten in 7.0 from the older storyIndexers preset to the new experimental_indexers contract; PR #23938 is the deprecation, and the new contract is the one documented at main-config-indexers. The 9.x line tightened the framework packages and dropped a number of legacy adapters, and the 10.x line — with 10.3.x the current release line at the time of writing — continues that direction while leaving the indexer contract and the Webpack lazyCompilation flag conceptually unchanged.

On the builder side, the picture has also evolved past the early Vite 5 references that show up in older posts. Vite has continued shipping major releases, and the @storybook/builder-vite package tracks Vite’s current line; the Storybook docs are the authoritative source for which Vite range a given Storybook version supports, because that pairing is the one that actually gets exercised in CI. The Webpack lazyCompilation flag still has not graduated out of experimental status in either Webpack itself or Storybook’s wrapper, which is why every reference to it in current Storybook docs still calls it experimental and dev-only.

Background on this in recent Storybook releases.

Anyone reading the 6.4 announcement post or the 6.5 lazy-compilation post as a current reference is reading something that has been technically correct in spirit and stale in detail for several major versions. The canonical MIGRATION.md on the storybookjs/storybook repository is the live primary source for what has actually changed, and it is the right document to consult before debating which flags to flip.

The takeaway is small but practical. Most of the boot win attributed to “on-demand compilation” is the static CSF indexer running ahead of Webpack — that is on by default, you do not need a flag, and it is what makes the sidebar feel instant. Lazy compilation is the right secondary lever only on Webpack-locked projects with hundreds of stories, and only if you can stomach the first-visit click pause; because it is a dev-mode feature, it is not the thing to worry about for production chunking, which is what storybook build in CI is for. If your project is not Webpack-locked, switching to the Vite builder retires most of the question entirely.

References