Performance
Measure before you optimize. Components in Solid do not re-render, so a slow page is rarely a component running too often. It is usually one derivation doing too much work, one write reaching thousands of readers, a list rebuilding rows it could keep, or requests that wait on each other. This guide shows how to find which one with the browser profiler and Solid's attribution tools, and what to change for each. The running example is a catalog page with 2,000 products and a search box.
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.
Wrap suspect code in performance.mark() calls to get named spans in the flame chart.
The profiler tells you where the time went. Attribution tells you why that code ran. It is a development-build recorder that logs every scope that re-ran, what caused 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().scopesranks scopes byselfMs.wastedMsis the time spent on runs whose result did not change.costs().writesranks writes by how much re-running each one caused downstream.feedback().flightscounts, per async source, how many requests started and how many wereabandonedbefore they landed. A high abandon count usually means a request per keystroke.feedback().fallbackscounts how often eachLoadingboundary showed its fallback, and how many of those wereflashesunder 150ms.
Name your scopes before you measure: createMemo(fn, { name: "visible" }), createStore(value, { name: "catalog" }).
The tables and why() chains use those names, and an unnamed memo prints as computed.
While attribution is on, Solid also warns when a scope is hot or wide:
| Code | Fires when |
|---|---|
HOT_SCOPE_RERUNS | a scope re-runs 120+ times in one second |
HOT_SCOPE_TIME | a scope spends 8ms+ of compute in one second |
WIDE_SCOPE_DEPS | a scope tracks 200+ sources |
HUGE_FAN_OUT | one write reaches 250+ subscribers (data.write names it) |
Two warnings are on in development even without attribution: HUGE_FAN_OUT at 2,000 subscribers and HUGE_FAN_IN at 2,000 sources.
Debugging reactivity explains how to read each report.
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 and type one character.
The Propagation track shows the write and everything it reached, labelled like query "m" → "mu" — input on input#search · 2,003 runs, 2,000 unchanged.
Effects and Memos show one span per re-run, flagged when the run changed nothing.
Async shows each request from start to landing (abandoned ones are flagged) and each Loading fallback from shown to hidden.
Interactions, Navigations, Holds and Server fill in the rest: the keystroke from input delay to settle, route changes, what each held update waited on, and server time for each server-function call.
Diagnostics also show up as markers on the panel's Timings track.
Labels start at the nearest component you wrote, like <Catalog> › visible, and an unnamed memo shows up as computed here too, so name your scopes first.
The tracks do not run the cost checks (HOT_SCOPE_RERUNS and friends) unless you ask for them with enablePerformanceTracks({ attribution: { checks: true } }).
They coexist with the console session above and with any observability adapter.
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. So do not go looking for a component that runs too often. If a component function shows up in a profile, you are looking at its first and only run.
The real problems are narrower, and each one has a name in the console:
| What the profile shows | Code | Fix |
|---|---|---|
| One derivation runs on every keystroke, in every reader | HOT_SCOPE_RERUNS, HOT_SCOPE_TIME | Expensive derivations |
| One write re-runs thousands of small scopes | HUGE_FAN_OUT | Stores at scale |
| Readers update twice per change, one flush apart | EFFECT_RELAY_TEAR | Avoid unnecessary effects |
| Rows are disposed and recreated for the same records | UNSTABLE_LIST_IDENTITY, IMMUTABLE_UPDATE_IN_STORE | Lists |
| A memo notifies readers although nothing changed | UNSTABLE_MEMO_OUTPUT | Expensive derivations |
| Requests start one after another | ASYNC_WATERFALL | Waterfalls |
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 itconst visible = () => products.filter((product) => product.name.includes(query().trim()));
// Prefer: a memo runs once per change and hands the result to every readerconst 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 onceconst 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
In a list, every mistake is multiplied by the number of rows.
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 covers 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.
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 oneconst [products] = createStore(async () => getProducts(), [] as Product[], { name: "catalog",});
// A derived per-key view: each row reads its own keyconst 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
Attribution tells you which of three causes it is.
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
attributionand readcosts()andfeedback()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
Forthe store's objects; amapthat produces new objects rebuilds every row. - Derive independent requests from inputs you already have, so they start together.
- Split code with
lazyat the boundary between the first page and the rest, and turn onpreloadLinks: intentPreload()so the router preloads chunks on hover. - Wrap each streamed region in a
Loadingboundary 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
whychains behind them. - Rendering and SSR:
renderToStream, hydration, and where the boundaries on this page end up in the HTML.