Summary
@solidjs/signals@2.0.0-rc.7 (also solid-js / @solidjs/web rc.7, prod and dev builds).
Mounting N rows in one flush (a <For> / mapArray mount) is O(N²) when each row creates a createEffect whose source is written while the row is being created. The common case is an effect over the element ref:
const [el, setEl] = createSignal<HTMLDivElement>();
createEffect(() => el(), (el) => el && observer.observe(el));
return <div ref={setEl} />;
The ref write makes the effect dirty right after it is created. User effects run in the effect phase, so every such effect stays in the pure heap until the whole synchronous mount is done. Each later row's first memo read calls markHeap, which re-walks the entire heap because runHeap cleared its _marked flag. Total marking work is roughly N × (dirty effects so far). Doubling N costs about 4x. Without the ref effect the same mount is linear at about 1 µs per row.
Reproduction (headless, only @solidjs/signals)
mkdir solid-markheap && cd solid-markheap
npm init -y >/dev/null && npm i solid-js@2.0.0-rc.7 >/dev/null
cat > repro.mjs <<'JS'
// Headless repro (only @solidjs/signals needed): mounting N "rows" inside a
// mapArray during one flush is O(N^2) when each row creates an effect whose
// source is written while the row is being created (the ref-signal pattern).
// node repro.mjs -> timing table
// node --cpu-prof repro.mjs -> markHeap/markNode dominate
import { createEffect, createMemo, createRenderEffect, createRoot, createSignal, flush, mapArray } from "@solidjs/signals";
console.warn = () => {}; // dev build: silence HUGE_FAN_OUT on the shared viewport memo (by design here)
function mount(N, { refEffects }) {
let dispose;
let ms = 0;
createRoot((d) => {
dispose = d;
const [zoom] = createSignal(1);
const viewport = createMemo(() => 600 / zoom());
const [items, setItems] = createSignal([], { ownedWrite: true }); // the bench writes from inside the root
const rows = createMemo(
mapArray(items, (i) => {
// per-row memo, read by a sync "binding" effect (what compiled JSX does)
const visible = createMemo(() => i * 10 < viewport());
createRenderEffect(() => visible(), () => {}, { sync: true });
// effect over the row's element ref; the ref is assigned while the row
// mounts, so the effect is DIRTY and waits in the pure heap until the
// effect phase, i.e. for the rest of the whole synchronous mount.
// ownedWrite: the ref write lands inside the row owner, as a compiled
// `ref={setEl}` does (the dev owned-scope guard would throw otherwise).
const [el, setEl] = createSignal(null, { ownedWrite: true });
for (let k = 0; k < refEffects; k++) createEffect(() => el(), () => {});
setEl({ i });
return visible;
}),
);
createRenderEffect(() => rows().length, () => {});
flush();
const t0 = performance.now();
setItems(Array.from({ length: N }, (_, i) => i));
flush();
ms = performance.now() - t0;
});
dispose();
return ms;
}
for (const refEffects of [0, 1, 4]) {
console.log(`\nrefEffects per row = ${refEffects}`);
let prev = 0;
for (const n of [1000, 2000, 4000, 8000]) {
const ms = mount(n, { refEffects });
console.log(` N=${String(n).padStart(5)} ${ms.toFixed(1).padStart(8)} ms ${((ms / n) * 1000).toFixed(1).padStart(6)} us/row${prev ? ` ${(ms / prev).toFixed(2)}x per 2x N` : ""}`);
prev = ms;
}
}
JS
echo "--- prod build ---"; node repro.mjs
echo "--- dev build ---"; node --conditions=development repro.mjs
Results (Node 24.13, macOS, Apple Silicon)
Prod build:
refEffects per row = 0
N= 1000 3.5 ms 3.5 us/row
N= 2000 3.5 ms 1.7 us/row 0.98x per 2x N
N= 4000 4.5 ms 1.1 us/row 1.30x per 2x N
N= 8000 8.8 ms 1.1 us/row 1.96x per 2x N
refEffects per row = 1
N= 1000 4.5 ms 4.5 us/row
N= 2000 18.1 ms 9.0 us/row 4.06x per 2x N
N= 4000 46.6 ms 11.7 us/row 2.58x per 2x N
N= 8000 231.4 ms 28.9 us/row 4.96x per 2x N
refEffects per row = 4
N= 1000 12.0 ms 12.0 us/row
N= 2000 44.1 ms 22.1 us/row 3.68x per 2x N
N= 4000 171.8 ms 43.0 us/row 3.89x per 2x N
N= 8000 685.8 ms 85.7 us/row 3.99x per 2x N
Dev build: same curve, steeper. 1 effect per row: 227 ms at N=8000. 4 effects per row: 1181 ms at N=8000.
node --cpu-prof repro.mjs at N=8000 with 1 effect per row: 49% self time in read, 29% in markHeap. About 78% of the mount is marking.
Browser reproduction (compiled JSX)
Same shape through the compiler: one memo read by a binding, one createEffect over the row's ref.
// Mount N rows. Each row: one memo read by a binding, one createEffect over
// the row's element ref. ?n=8000 -> mount time is O(N^2).
import { render } from "@solidjs/web";
import { createEffect, createMemo, createSignal, For } from "solid-js";
const N = Number(new URLSearchParams(location.search).get("n") ?? 4000);
const [zoom] = createSignal(1);
const viewport = createMemo(() => 600 / zoom());
const items = Array.from({ length: N }, (_, i) => i);
const Row = (props: { i: number }) => {
const visible = createMemo(() => props.i * 10 < viewport());
const [el, setEl] = createSignal<HTMLDivElement>();
// The ref signal is written while this row mounts, so this effect is dirty
// and waits in the pure heap until the effect phase, i.e. for the whole
// synchronous mount of every remaining row. Each later row's memo pull
// re-marks the entire heap (markHeap): O(N^2).
createEffect(() => el(), () => {});
return <div ref={setEl} class={visible() ? "in" : "out"} />;
};
const t0 = performance.now();
render(() => <For each={items}>{(i) => <Row i={i} />}</For>, document.getElementById("app")!);
const ms = performance.now() - t0;
(window as unknown as { __mount: number }).__mount = ms;
document.getElementById("out")!.textContent = `N=${N} mount=${ms.toFixed(0)}ms`;
N= 1000 7 ms 7 us/row
N= 2000 15 ms 7 us/row 2.07x per 2x N
N= 4000 57 ms 14 us/row 3.92x per 2x N
N= 8000 238 ms 30 us/row 4.19x per 2x N
N=16000 799 ms 50 us/row 3.35x per 2x N
A CDP sampling profile at N=8000 puts 78% of samples in markNode/markHeap. Deleting the createEffect(() => el(), ...) line makes the mount linear (about 5 µs per row up to 16k rows). <Dynamic>, store-backed rows and per-row projections do not trigger it on their own.
Where it goes wrong (rc.7 source, heap.ts / core.ts)
createEffect reads el(). ref={setEl} writes it right after. The effect is now dirty and enqueued in the pure heap. User effects run in the effect phase, so it stays there until the mount finishes.
- The row's compiled bindings are sync render effects. Running them runs the heap (
runHeap), which clears heap._marked.
- The next row reads a memo for the first time (
read → prepareComputed → recompute). Validation calls markHeap(dirtyQueue). The flag is clear, so it walks every heap level and calls markNode on every entry, including all the dirty effects from earlier rows, none of which can affect the memo being validated.
Instrumented in a real app (10k rows, 4 such effects per row): markHeap was called 2N + 7 = 20 007 times, the heap held up to 4N + 2 = 40 002 entries, and every entry was an effect node (_type 2). That is about 250 million heap visits in one mount.
Impact
Any component that wires its element in an effect (ResizeObserver, listeners, focus) pays this once per instance mounted in the same flush. The cost does not show up in attribution because it is marking, not computation.
In Solid Flow (@dschz/solid-flow) at 10k nodes this was about 5 s of a 9.7 s production mount. We worked around it by wiring the element from the ref callback instead (ref={(el) => runWithOwner(owner, () => wire(el))}), so no effect is dirty at creation. The mount went to 4.3 s and markHeap calls from 20,007 to 8. That fixes our own wrappers only. A user's custom node component written with the effect-over-ref pattern hits it again.
Possible directions
- Track which heap entries have already been marked and only mark entries added since, instead of clearing
_marked in runHeap.
- Skip effect-typed nodes when marking for memo validation. A dirty user effect cannot make a memo stale.
- Defer marking until a batch actually needs to validate a computed with a dirty ancestor in the heap.
Platform
- macOS 15 (Darwin 24.6.0), Apple Silicon
- Node 24.13.0; Chromium (Playwright) for the browser numbers
solid-js@2.0.0-rc.7, @solidjs/signals@2.0.0-rc.7, @solidjs/web@2.0.0-rc.7
Summary
@solidjs/signals@2.0.0-rc.7(alsosolid-js/@solidjs/webrc.7, prod and dev builds).Mounting N rows in one flush (a
<For>/mapArraymount) is O(N²) when each row creates acreateEffectwhose source is written while the row is being created. The common case is an effect over the element ref:The ref write makes the effect dirty right after it is created. User effects run in the effect phase, so every such effect stays in the pure heap until the whole synchronous mount is done. Each later row's first memo read calls
markHeap, which re-walks the entire heap becauserunHeapcleared its_markedflag. Total marking work is roughly N × (dirty effects so far). Doubling N costs about 4x. Without the ref effect the same mount is linear at about 1 µs per row.Reproduction (headless, only
@solidjs/signals)Results (Node 24.13, macOS, Apple Silicon)
Prod build:
Dev build: same curve, steeper. 1 effect per row: 227 ms at N=8000. 4 effects per row: 1181 ms at N=8000.
node --cpu-prof repro.mjsat N=8000 with 1 effect per row: 49% self time inread, 29% inmarkHeap. About 78% of the mount is marking.Browser reproduction (compiled JSX)
Same shape through the compiler: one memo read by a binding, one
createEffectover the row's ref.A CDP sampling profile at N=8000 puts 78% of samples in
markNode/markHeap. Deleting thecreateEffect(() => el(), ...)line makes the mount linear (about 5 µs per row up to 16k rows).<Dynamic>, store-backed rows and per-row projections do not trigger it on their own.Where it goes wrong (rc.7 source,
heap.ts/core.ts)createEffectreadsel().ref={setEl}writes it right after. The effect is now dirty and enqueued in the pure heap. User effects run in the effect phase, so it stays there until the mount finishes.runHeap), which clearsheap._marked.read → prepareComputed → recompute). Validation callsmarkHeap(dirtyQueue). The flag is clear, so it walks every heap level and callsmarkNodeon every entry, including all the dirty effects from earlier rows, none of which can affect the memo being validated.Instrumented in a real app (10k rows, 4 such effects per row):
markHeapwas called 2N + 7 = 20 007 times, the heap held up to 4N + 2 = 40 002 entries, and every entry was an effect node (_type2). That is about 250 million heap visits in one mount.Impact
Any component that wires its element in an effect (ResizeObserver, listeners, focus) pays this once per instance mounted in the same flush. The cost does not show up in attribution because it is marking, not computation.
In Solid Flow (
@dschz/solid-flow) at 10k nodes this was about 5 s of a 9.7 s production mount. We worked around it by wiring the element from the ref callback instead (ref={(el) => runWithOwner(owner, () => wire(el))}), so no effect is dirty at creation. The mount went to 4.3 s andmarkHeapcalls from 20,007 to 8. That fixes our own wrappers only. A user's custom node component written with the effect-over-ref pattern hits it again.Possible directions
_markedinrunHeap.Platform
solid-js@2.0.0-rc.7,@solidjs/signals@2.0.0-rc.7,@solidjs/web@2.0.0-rc.7