Performance

The catalog page renders 2,000 products. Typing in its search box lags a beat behind each keystroke, and the first paint of the page waits until the product list, the cart badge, and the account menu have all answered. Someone suggests memoizing everything; someone else suggests a virtual list.

Both may be right, and neither is worth doing before a measurement says which part is slow. This guide is about taking that measurement and reading which knob it points at. It does not make speed claims; it describes what Solid does and does not do when a value changes, so the profile can be read against it.


Measure before changing

Start in the browser's Performance panel: record, type one character in the search box, stop. A long task after the keystroke shows the call stack that spent the time, and performance.mark() calls around suspect code give the flame chart named spans to look for. That tells you where the milliseconds went; the next tool tells you why that code ran.

Attribution is Solid's development-build recorder of every scope that re-ran, what changed to cause it, and how long it took:

import { attribution, costs, feedback } from "solid-js/attribution";
attribution.enable();
// type in the search box, then:
console.table(costs().scopes);
console.table(feedback().flights);

costs().scopes ranks scopes by selfMs, with wastedMs for the time spent on runs whose result did not change, and costs().writes ranks root writes by the downstream re-run time each one caused. feedback().flights counts, per async source, how many requests started and how many were abandoned before landing; a high abandon count is the request-per-keystroke signature. feedback().fallbacks counts how often each Loading boundary showed its fallback and how many of those were flashes under 150ms.

Name the scopes first: createMemo(fn, { name: "visible" }), createStore(value, { name: "catalog" }). Every table and every why(visible) chain refers to nodes by that name, and an unnamed memo prints as computed.

While attribution is enabled, Solid also warns on its own when a scope re-runs 120 or more times in one second (HOT_SCOPE_RERUNS), spends 8ms or more of compute in one second (HOT_SCOPE_TIME), tracks 200 or more sources (WIDE_SCOPE_DEPS), or when one write reaches 250 or more subscribers (HUGE_FAN_OUT at the engine's fanOut threshold, with data.write naming the write). Two warnings are always on in development: HUGE_FAN_OUT from 2,000 subscribers and HUGE_FAN_IN from 2,000 sources. Debugging reactivity explains how to read each report.

Development and observe builds only

Attribution and the diagnostics on this page exist in the development build and, without console output, in the observe build. A production build strips them, so measure the production bundle with the browser profiler and the development bundle with attribution, and expect the second to be slower in absolute terms.


See the records on the Performance panel

The tables say which scope ran; the Performance panel says when, against the browser's own main-thread and network tracks. @solidjs/web/performance-tracks paints the attribution engine's records there as a Solid track group, so the two measurements sit on one timeline:

import { enablePerformanceTracks } from "@solidjs/web/performance-tracks";
const release = enablePerformanceTracks();
// record, type one character in the search box, stop; then:
release();

Record a profile, type one character, and the group shows a wave on Propagation labelled by the write and what it reached, query "m" → "mu" — input on input#search · 2,003 runs, 2,000 unchanged, with every run inside it beneath, labelled by what made it run. Effects and Memos carry one span per re-run, colored by self time and marked as a warning when the run changed nothing; Async shows each request from kickoff to landing, with abandoned ones as warnings, and each Loading fallback from shown to hidden; Holds shows each wait labelled by its blockers; Interactions shows the keystroke from input delay through the handler to settle; Navigations shows each route change; Server shows the page's server-function calls and, beneath each, the server's own time for it. Every finding while the tracks are on is also a marker on the panel's Timings track, HOT_SCOPE_RERUNS — <Catalog>, and a warning or worse is annotated for the Insights sidebar with a link to the code's repair guide. The cost findings, such as HOT_SCOPE_RERUNS, appear only while some hold runs the cost checks: the console session above does, and the tracks' own hold does not unless asked.

A label starts at the nearest component you wrote, <Catalog> › visible rather than the full path from the document root, because the panel elides the middle of a label that does not fit. Solid's own flow components (Show, For, Loading, and the rest) never anchor a label, so a node under one reads <Card> › <Show> › effect; the unshortened path is the span's Owner path property, Node its runtime name, and in dev the span's tooltip is the same [why-run] chain the console prints. Give scopes names before you record here for the same reason as above: an unnamed memo is computed on the track as well.

The Server track reads the response's Server-Timing header, which a dev server writes for every request and an observe server writes while something on the server subscribes to the matching record: solid-invocation from an "invocation" record on a server-function response, and on a document solid-shell from the "render" record plus one solid-boundary per Loading boundary the shell waited on. The document's own metrics appear as shell · server and boundary <App> › <Loading> · server when the tracks enable, and a call's appear beneath its span once the browser's resource timing places it.

enablePerformanceTracks() takes its own records-only hold on the engine, with log: false and checks: false, so it coexists with the console session above or with an adapter, and the console stays quiet only while nothing else asks for the log. Pass enablePerformanceTracks({ attribution: { checks: true } }) to run the cost checks (hotRuns, hotTime, wideDeps, unstableMemos, fanOut, wastedRecompute) under the tracks' own hold. It accepts other attribution options for that hold, minMs to floor the spans it paints (0 in dev, 0.05 in the observe build), and rich to switch between performance.measure entries with tooltips and properties and plain console.timeStamp entries. An observe build's tracks inherit the engine's values: "none", so no value previews and no element text reach the panel; pass attribution: { values: "labels" } for the control labels, or in dev "none" for a timeline without user data. The enablePerformanceTracks reference lists the options.


What does not re-run

A component function runs once. The JSX it returns compiles to expressions that each track what they read, so a signal write re-runs the expressions that read that signal and nothing else. A component that runs too often is therefore not a failure mode to look for, and a profile that shows a component function high in the stack is showing its first and only run.

The failure modes that do exist are narrower, and each has a name in the console:

What the profile showsCodeFix
One derivation runs on every keystroke, in every readerHOT_SCOPE_RERUNS, HOT_SCOPE_TIMEExpensive derivations
One write re-runs thousands of small scopesHUGE_FAN_OUTStores at scale
Readers update twice per change, one flush apartEFFECT_RELAY_TEARAvoid unnecessary effects
Rows are disposed and recreated for the same recordsUNSTABLE_LIST_IDENTITY, IMMUTABLE_UPDATE_IN_STORELists
A memo notifies readers although nothing changedUNSTABLE_MEMO_OUTPUTExpensive derivations
Requests start one after anotherASYNC_WATERFALLWaterfalls

Expensive derivations

The catalog filters 2,000 products by the query. Where that filter lives decides how many times it runs:

import { For, createMemo, createSignal, createStore } from "solid-js";
const [products] = createStore(initialCatalog, { name: "catalog" });
const [query, setQuery] = createSignal("");
// Avoid: a plain function runs in every scope that reads it
const visible = () =>
products.filter((product) => product.name.includes(query().trim()));
// Prefer: a memo runs once per change and hands the result to every reader
const visible = createMemo(
() => products.filter((product) => product.name.includes(query().trim())),
{ name: "visible" }
);
<p>{visible().length} results</p>;
<For each={visible()}>{(product) => <ProductCard product={product} />}</For>;

Run the Avoid version and type one character: the filter runs twice, once for the count and once for the list, because a plain function is re-evaluated by each tracking scope that calls it. In costs(), visible does not appear at all, since a plain function is not a scope; its time is charged to the scopes that called it. In the Prefer version the memo runs once, both readers receive the stored array, and costs() shows one visible row.

A memo costs a node in the graph and a comparison on each run; for a cheap expression with one reader, the function is smaller. Reach for the memo when several readers share the result, when the computation is expensive, or when the result is often unchanged. That last case is the equality gate: a memo compares its new value to the previous one and does not notify readers when the two are equal, so nothing downstream runs.

The gate compares by reference, which a filter defeats: every run produces a new array. Move the equality boundary to where the value is a primitive:

// Prefer: the trimmed query settles first, so " mug" and "mug" filter once
const needle = createMemo(() => query().trim().toLowerCase(), {
name: "needle",
});
const visible = createMemo(
() =>
products.filter((product) => product.name.toLowerCase().includes(needle())),
{ name: "visible" }
);

Type a trailing space and needle recomputes, produces the same string, and stops there; visible and the 2,000-row list do not run. When a memo must return an object or array that is often equal in content, pass an equals option that compares by content; without one, attribution reports [UNSTABLE_MEMO_OUTPUT] after four consecutive new-but-equivalent results, because every subscriber re-ran for nothing.


Lists

A list is where one mistake is paid once per row. For keeps a row when the same object comes back and rebuilds it when a new object appears, so the expensive version of a list is the one that hands For new objects for the same records:

// Avoid: a new object per product on every change, so every row is rebuilt
<For
each={visible().map((product) => ({
...product,
inCart: cartIds().has(product.id),
}))}
>
{(product) => <ProductCard product={product} inCart={product.inCart} />}
</For>;
// Prefer: pass the store's objects and read the derived flag in the row
<For each={visible()}>
{(product) => <ProductCard product={product} inCart={inCart[product.id]} />}
</For>;

Run the Avoid version and add one item to the cart: all 2,000 rows are disposed and recreated, and attribution reports [UNSTABLE_LIST_IDENTITY]. In the Prefer version inCart is a store keyed by product id, built in Stores at scale, so adding an item updates the one row whose key flipped.

The Lists guide owns the rest: keeping identity across a refetch, selection without touching every row, and rendering a window with Repeat when 2,000 rows should not all be in the DOM.


Waterfalls

The first paint waits on three requests because each one starts when the one before it lands: the product needs nothing, the brand read waits for the product, and the brand's catalog waits for the brand. Derive each request from the input you already have, so the ones that do not depend on each other start together, and join the ones that do on the server. With attribution on, three or more sequential flights of 50ms or more print [ASYNC_WATERFALL] with the serialized time; a two-flight chain is recorded at info only. Dependent requests shows the Avoid and Prefer versions, and a route preload starts the reads before the component runs at all.


Code splitting

The account area is a third of the bundle and most visitors never open it. lazy turns an import into a component that loads its chunk on first render:

import { Loading, lazy } from "solid-js";
const Account = lazy(() => import("./account/Account"));
<Loading fallback={<AccountSkeleton />}>
<Account />
</Loading>;

Render <Account /> for the first time and the dynamic import starts; while it is in flight the component suspends through the nearest Loading boundary, which shows the skeleton. Call Account.preload() to start the import early, for example from a link's onMouseEnter. For a module that is not a component, such as a route table or a formatting library, lazyModule is the same cached import with .preload(), and the server can preload it before hydration.

With Solid Router the split usually happens at the route. A route whose component is a lazy component has its chunk loaded on first navigation, and a children: () => import("./account/routes") thunk defers a whole route table the same way. Link preloading is opt-in: create the router with createRouter({ routes, preloadLinks: intentPreload() }) and the chunk loads when the user hovers, focuses, or touches a link to it. Load a route subtree lazily and Preloading from links cover both.

A chunk is a round trip

Splitting a component that the first page needs adds a request before that page can paint. Split at the boundary between what the first page shows and what it does not, and leave the header, the product grid, and the cart badge in the main bundle.


Server rendering and streaming

With ssr: true, renderToStream sends the synchronous shell first and then a fragment for each Loading boundary as its content settles. An async read with no boundary above it holds the shell, so the visitor sees nothing until that read lands:

// Avoid: the shell waits for the catalog
<main>
<Header />
<ProductGrid products={products} />
</main>;
// Prefer: the shell carries the header and a fallback; the grid streams in
<main>
<Header />
<Loading fallback={<GridSkeleton />}>
<ProductGrid products={products} />
</Loading>
</main>;

Load the page with the Avoid version and nothing is sent until the catalog query resolves. With the Prefer version the header and the skeleton arrive at once, and the grid replaces the skeleton when its data lands.

When one read must be in the first HTML, for a crawler or a link preview, pass deferStream: true to that memo; the shell then waits for that read instead of sending its boundary's fallback. Streaming rendering covers the output forms, and Choose a rendering mode weighs streaming against a static shell and prerendering.


Stores at scale

A store holding 2,000 products does not create 2,000 sets of tracking nodes. The proxy creates a node for a property the first time a tracking scope reads it, so the rows on screen cost nodes and the rest cost nothing until they scroll into view. Two store features keep that cheap as the data changes:

import { createProjection, createStore } from "solid-js";
// Server refreshes reconcile by id, so unchanged rows notify no one
const [products] = createStore(async () => getProducts(), [] as Product[], {
name: "catalog",
});
// A derived per-key view: each row reads its own key
const inCart = createProjection<Record<string, boolean>>(
(draft) => {
for (const key of Object.keys(draft)) delete draft[key];
for (const item of cart.items) draft[item.id] = true;
},
{},
{ name: "inCart" }
);

Refetch the catalog after a price change and one text node updates; the other 1,999 rows read properties whose values did not change and are not notified. Add an item to the cart and inCart gains one key, so one row's inCart read runs.

The alternative, every row reading cart.items.some((item) => item.id === product.id), subscribes 2,000 scopes to cart.items, and adding an item re-runs all of them. Development reports that write as [HUGE_FAN_OUT] from 2,000 subscribers, and with attribution on the same code names the signal from 250, the engine's fanOut threshold. A projection or a store used as a map keyed by id is the repair both messages suggest.

For a record map whose rows are replaced wholesale rather than edited, createStore(value, { shallow: true }) tracks the root keys and stores each value by reference, so no per-field nodes are created for rows nothing reads field by field.


Common problems

Typing in the search box lags

Three causes, and attribution separates them. If costs().scopes puts a filter or sort at the top, it runs in every reader; make it a memo and put an equality boundary on the normalized query, as in Expensive derivations. If feedback().flights shows most requests abandoned, a request starts per keystroke; debounce in the input handler, as Search as you type shows. If the input itself shows the previous character, it is bound to query() while a held update waits on results; bind latest(query) instead.

Every row updates when one product is selected

Each row reads the selected id, so a selection change re-runs 2,000 scopes and development prints [HUGE_FAN_OUT]. Keep the selection in a store keyed by id and read selected[product.id] in the row; Select a row shows the two versions.

The list rebuilds on every refresh

The refetch produced new objects for the same records, so For disposed every row, and attribution reports [UNSTABLE_LIST_IDENTITY]. Load the list into a store created from a function, or pass keyed={(item) => item.id}; see Keep row identity across updates.

First paint waits for all the data

An async read sits above every Loading boundary, so the streaming shell waits for it, or three reads run in sequence and the page waits for their sum. Put a boundary around the region that depends on the read, and check the console for [ASYNC_WATERFALL] to find the chain.

The development build is slow and the production build is fine

Attribution, the diagnostics, and the [why-run] console groups run in development only. Measure timings on a production build; use the development build to find out which scope ran and why.


Recap

  • Record a profile first, then enable attribution and read costs() and feedback() to see which scope ran and what caused it; enablePerformanceTracks() puts the same records on the Performance panel's timeline.
  • A component runs once; look for a hot derivation, a wide write, a relaying effect, or a rebuilt list, not a re-rendering component.
  • Put a shared or expensive derivation in a memo, and put an equality boundary on a primitive so a change that settles to the same value stops there.
  • Hand For the store's objects; a map that produces new objects rebuilds every row.
  • Derive independent requests from inputs you already have, so they start together.
  • Split code with lazy at the boundary between the first page and the rest, and turn on preloadLinks: intentPreload() so the router preloads chunks on hover.
  • Wrap each streamed region in a Loading boundary so the shell does not wait for its data.
  • Keep per-row flags in a store or projection keyed by id, so a change touches the rows whose key flipped.

Next steps

  • Lists: row identity, selection, and windowing over the catalog, with the diagnostics that fire when rows are rebuilt.
  • Data fetching patterns: parallel reads, preload, sharing one request across components, and the full [ASYNC_WATERFALL] report.
  • Debugging reactivity: how to read every diagnostic named on this page and the why chains behind them.
  • Rendering and SSR: renderToStream, hydration, and where the boundaries on this page end up in the HTML.
Last updated: 10/15/26, 9:29 AMEdit this pageReport an issue with this page