“If I just wrote element.textContent = String(count) and called it from a click handler, that would be faster than anything Phaze or React does. Why am I using a framework at all?”
Every framework comparison politely steps around this. We’re not going to.
When state changes, three things happen on screen:
Compute — the framework figures out what to update.
Apply — the actual DOM op (textContent = ..., setAttribute).
Layout / paint — the browser repaints. Outside our control.
“Apply” is irreducible — vanilla pays it too. “Compute” is where frameworks vary by 1–100×. That’s the tax.
Architecture, not microbench
approach
compute work per state change
vanilla
nothing — you wrote the exact DOM op
Phaze
walk the signal’s subscriber list; run the bindings that read it
Preact
re-render affected components, build new VNode tree, diff vs old, patch
React
re-render component tree, build new fiber tree, reconcile, schedule commits
Each row above the floor is the architectural cost of that framework, per update, regardless of how trivial the update is. A 1-character text change pays the same compute cost as a full repaint.
When setState fires in React, before any DOM operation happens, the framework does:
Mark the component dirty.
Schedule a re-render. Concurrent-mode scheduler decides priority.
Re-execute the component function. Every line of JSX runs again, building new VNodes.
Walk the children. Each child re-runs unless React.memo says it can skip.
Diff old VNode tree against new. Walk both trees in parallel, identify changes, generate patches.
Commit patches to the DOM. Apply the actual textContent / setAttribute operations.
Steps 1–5 are pure overhead vs vanilla. They scale with tree size, not with what changed. A setState that affects one tile in a list of 100 still re-runs all 100 tile components and diffs the result.
The conventional Band-Aid: liberal React.memo, useMemo, useCallback. None of these change the architecture. They paper over leaves of the tree with manually-maintained referential-equality contracts. Forget a dependency in useMemo and you have a stale-closure bug. The diff and reconciliation still run; you’ve just shrunk the tree they walk. You’re paying with developer attention to make the framework less wrong.
This is the architectural latency the React community has lived with since 2013.
Preact narrows the gap. It doesn’t change the road.
A smaller VNode shape (no fiber / concurrent infrastructure).
A simpler reconciler with fewer phases.
Less per-tree bookkeeping.
But the architecture is identical: state change → re-run component → build new VNode tree → diff → patch. Work still scales with tree size.@preact/signals shrinks the tree (signal-bound text nodes update without re-running the component) but top-down render, diff, and commit are unchanged.
Phaze has no diff phase, no VNode tree, no reconciliation, no fiber. The runtime keeps a graph: signal → bindings that read it. When a signal changes:
Walk the signal’s subscriber list.
For each binding, schedule it.
Each binding does a single node.textContent = ... (or setAttribute, etc.) on the exact DOM node it owns.
That’s the entire compute step. No tree walk. No diff. No allocation per update.
When you write <span>{count}</span> in Phaze, the runtime registers exactly one subscription: “when count changes, update this text node.” The 99 other tiles in your list don’t subscribe to count. They don’t run. They aren’t inspected. They aren’t diffed. There is no list of 100 tiles to walk — there’s a graph, and count’s edge points to one place.
The bench measures this directly. Pulse mode (one tile out of 100 ticks per frame, for 10 seconds):
Architectural difference in numbers
framework
peak heap delta
Phaze
~150 KB
React
~360 KB
Preact
~1.26 MB
Numbers vary run-to-run. The gap is reproducible. Preact’s heap pressure is high because every pulse builds a fresh VNode tree of all 100 tiles — even though 99 are unchanged. Phaze allocates almost nothing per pulse because only one binding reads the changed signal.
This isn’t a microoptimization. It’s the architectural difference made concrete.
Phaze still has overhead vs raw element.textContent = String(count):
A signal() is a function with a Set of subscribers. Reading and writing has cost.
Bindings run inside an effect() — its own scheduled computation.
The scheduler microtask-queues notifications.
In a tight loop of pure-state writes outside any framework integration, vanilla is roughly 1.5–2× faster per write. At any realistic scale — hundreds of bindings, real DOM, real apps — the gap closes. The per-write overhead is dwarfed by the DOM operation itself, and you’ve saved an enormous engineer effort by not writing those bindings by hand.
This is the residual tax. Phaze doesn’t pretend it’s zero. It is, however, the smallest tax of any production-grade framework that exists.
❌ Faster than vanilla. Vanilla wins by definition.
❌ Magic. If your app does 5 MB of layout work per click, no framework choice helps.
❌ Zero overhead. The 1.5–2× vs vanilla is real and won’t ever be zero.
What Phaze does claim:
✅ Per-update cost scales with what changed, not with tree size. This is the single biggest architectural difference vs React/Preact, and it compounds as your app grows.
✅ A 1000-component React app pays for all 1000 on every state change (minus what memo skips, fragilely). A 1000-component Phaze app pays for the bindings that read the changed signal — usually one or two.
✅ Runtime size is small enough (~3 KB brotli) that this property is essentially free at the bundle level.
✅ The bench evidence is reproducible. Run it yourself.
// Phaze. One subscription per binding. Tree size doesn't matter.
const count = signal(0)
functionTile() {
return<span>{count}</span>
// ↑ this text node subscribes to `count`. That's it.
}
// React. Re-runs the component on every change to anything in scope.
const [count, setCount] = useState(0)
functionTile() {
return<span>{count}</span>
// ↑ the WHOLE Tile function re-runs when count changes.
// Then React diffs the new VNode tree.
// Multiply by 100 tiles.
}
The difference between those two snippets is the architectural latency this whole page has been describing. Phaze makes it visible. React makes it the default cost of admission.