Change Detection and Rendering Engines
Change Detection and Rendering Engines
Three engine families for solving the DOM update problem — Virtual DOM tree diffing (React), change detection with zone.js/OnPush/Signals (Angular), and fine-grained reactivity with compiler output (Solid/Svelte) — each with distinct performance profiles, scheduling models, and developer tradeoffs.
Intent
The DOM update problem: when application state changes, which DOM nodes need to update, and how does the framework discover this? Three genuinely different architectural answers exist along a spectrum.
React diffs a virtual tree on every render — the Virtual DOM is a plain JavaScript object tree; the reconciler diffs new vs previous on every state change using two O(n) heuristics. Angular checks the component tree on async events, progressively narrowing scope with OnPush and then Signals. Fine-grained reactivity frameworks (Solid, Svelte) skip tree diffing entirely by compiling templates to targeted DOM mutation calls tied to a reactive dependency graph.
These are not variants of one approach — they represent different tradeoffs between runtime overhead, developer ergonomics, and scheduling model.
TypeScript-only examples are used throughout this note — rendering engines are a frontend concern with no Java equivalent, consistent with the Micro-Frontends precedent in this vault. All examples draw from both React and Angular ecosystems in the same note, with fine-grained reactivity coverage via Solid.js and Svelte 5.
When NOT to Use
- Virtual DOM (React) is unsuitable when per-render overhead matters for >10,000 frequently-updating DOM nodes (animation-heavy canvas apps). Each render produces a new vDOM tree; diffing that tree on every frame at 60fps creates measurable CPU overhead. Solid or Svelte eliminate diffing overhead entirely by compiling to direct DOM mutations.
- Angular zone.js default is unsuitable for performance-sensitive apps with high async event frequency. Every resolved Promise, timeout, or fetch triggers a full-tree change detection walk. Use OnPush strategy or zoneless Signals instead — both narrow the detection scope dramatically.
- Fine-grained reactivity (Solid/Svelte) is unsuitable when the team is established in React or Angular. The ecosystem switching cost (tooling, hiring, patterns, third-party libraries) exceeds the performance gain for most business applications. The performance gap between a well-tuned Angular Signals app and Solid is small in practice.
- Do not hand-roll change detection logic — each engine's primitives (
key,OnPush,createSignal) encode domain knowledge about when DOM updates are needed. Duplicating this logic inuseEffectcallbacks or manual DOM diffing produces incorrect and unoptimised results. Each primitive is the correct abstraction. - Do not mix engines in the same component tree — pick one framework's rendering model per application. Virtual DOM, zone.js, and fine-grained reactivity make incompatible assumptions about ownership of DOM nodes. Micro-frontends are the one exception: composition at the shell level with a hard boundary between frameworks.
Engine Comparison Flowchart
flowchart TD
A([State Change]) --> B{Which engine?}
B -->|React Virtual DOM| C[Re-render component\nto new vDOM tree]
C --> D[Diff new vDOM vs\nprevious snapshot]
D --> E{Same element type\nat same position?}
E -->|Yes — same key or\nno key, same type| F[Reuse DOM node\nupdate changed props]
E -->|No — different type\nor different key| G[Unmount old subtree\nMount new subtree]
F --> H[Commit phase:\npatch real DOM]
G --> H
B -->|Angular Incremental DOM| I{Change detection\nmode?}
I -->|zone.js default| J[zone.js detects async event\nTrigger full-tree CD walk]
I -->|OnPush| K[Check: did @Input\nreference change?]
K -->|No — skip subtree| L[No DOM update]
K -->|Yes — mark dirty| M[Run CD on component\nand its children]
I -->|Zoneless Signals| N[Signal write marks\ncomponent dirty]
N --> O[Schedule micro-task\nPatch only dirty components]
J --> P[Patch DOM for\nchanged bindings]
M --> P
O --> P
B -->|Fine-Grained Reactive\nSolid · Svelte| Q[Signal/rune write\nnotifies subscribers]
Q --> R[Reactive graph walks\nonly affected nodes]
R --> S[Direct DOM mutation\non bound elements only]
S --> T([DOM updated])
H --> T
P --> T
L --> T
Suitability Matrix
| Dimension | Virtual DOM (React) | Angular CD (zone.js/OnPush/Signals) | Fine-Grained (Solid/Svelte) |
|---|---|---|---|
| Update granularity | Component subtree re-render | Component subtree (zone.js), component-only (OnPush/Signals) | Individual DOM node |
| Scheduling | Fiber priority lanes (interruptible) | Synchronous CD cycle (zone.js), micro-task (Signals) | Synchronous reactive graph walk |
| Diffing overhead | O(n) per render (n = vDOM nodes in subtree) | No diffing — binding check per component | No diffing — direct DOM mutation |
| Key abstraction | key prop + startTransition | OnPush + signal() + provideZonelessChangeDetection() | createSignal (Solid) / $state (Svelte) |
| Bundle impact | react-dom ~40KB gzip | zone.js ~13KB (removable with zoneless) | No runtime diffing engine — compiler output only |
| Best for | General-purpose SPAs, large teams, rich ecosystem | Enterprise Angular apps, progressive migration from zone.js to Signals | Performance-critical UIs, greenfield projects with small teams |
Engine 1: Virtual DOM (React)
React renders to a Virtual DOM — a plain JavaScript object tree — on every state change. The reconciler diffs the new vDOM against the previous snapshot using two O(n) heuristics. Naive general-purpose tree diffing is O(n³); React achieves O(n) by never comparing nodes across type boundaries and using stable keys to identify list children.
Reconciliation Algorithm — O(n) Heuristics
Two heuristics reduce reconciliation to O(n):
Heuristic 1 — Type heuristic: If root element types differ between renders at the same position in the tree, React tears down the old subtree entirely and builds a new one. React never attempts to reuse DOM nodes across different component types at the same tree position. This eliminates the need to compare structure across incompatible types.
Heuristic 2 — Key heuristic: Within lists, the key prop lets React match children by stable identity across re-renders. Without keys, React diffs positionally — a list reorder produces O(n) sequential mutations. With stable unique keys, React moves nodes — insert at front produces 1 insertion and 0 mutations, bringing list reconciliation to O(1) per operation.
Big-O annotation: Naive tree diff = O(n³). React O(n) heuristics work because they impose domain-specific constraints: same-position, same-type, stable-key. Violate these (index-as-key, toggling element types) and React falls back to full subtree replacement — still O(n) per subtree, but with unnecessary unmount/mount overhead.
Fiber Architecture — Time-Slicing and Priority Lanes
React Fiber (React 16+) rewrites the reconciler to make the render phase interruptible:
- Work is broken into fiber units — one fiber per component instance in the tree
- The render phase (reconciliation) can be paused mid-tree and resumed — the browser can process input events between fiber units
- A priority lane system distinguishes urgent updates (user input, animations) from transition updates (
startTransition,useDeferredValue) - Transition updates are lower priority and can be interrupted when a higher-priority update arrives
React Compiler automates useMemo/useCallback at build time, reducing reconciliation work by memoising more components without manual annotation. This is the direction of travel for reducing Virtual DOM overhead — but it is opt-in and not universally adopted as of React 19.2.4. The core runtime reconciliation model described above remains unchanged.
TS Example: Key-Based Reconciliation + Fiber Priority
// Source: legacy.reactjs.org/docs/reconciliation.html
// Source: react.dev/reference/react/useTransition
import { useTransition, useState } from 'react';
// Heuristic 1: element type — different types = full subtree replacement
// React never diffs across type boundaries
function PauseableCounter({ isPaused }: { isPaused: boolean }) {
return (
<>
{isPaused
? <p>Paused</p> // type: 'p' — if prev was <Counter />, full tear-down
: <Counter /> // type: Counter — if prev was <p>, full tear-down
}
</>
);
}
// Heuristic 2: key — stable identity for list children
// With key: React moves nodes — insert at front = 1 insertion + 0 mutations
const list = contacts.map(c => (
<ContactRow
key={c.id} // stable, unique — from data, not array index
contact={c}
/>
));
// Anti-pattern: index key causes state misplacement on reorder
// ❌ never use array index as key for dynamic lists
const badList = contacts.map((c, i) => (
<ContactRow key={i} contact={c} /> // index-as-key: state follows position, not data
));
// Fiber priority — mark expensive update as low-priority transition
function SearchResults() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
function handleSearch(value: string) {
setQuery(value); // urgent: update input immediately
startTransition(() => {
setResults(computeResults(value)); // low-priority: can be interrupted by input
});
}
return (
<div>
<input value={query} onChange={e => handleSearch(e.target.value)} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</div>
);
}Engine 2: Angular Change Detection
Angular does NOT use a Virtual DOM. Angular's own terminology is "change detection engine" — the community label "Incremental DOM" is informal and does not imply Angular depends on Google's open-source incremental-dom library. Angular's rendering compiles templates to imperative DOM update instructions; change detection determines when to run those instructions.
Three strategies exist, representing a progressive migration path from coarse to fine scope.
Zone.js Default Cycle
Zone.js monkey-patches browser APIs (setTimeout, Promise, fetch, addEventListener) at application bootstrap. When any async operation completes, zone.js notifies Angular, which runs change detection from the root component downward — checking every binding in every component. This is O(n) per cycle where n = total component count.
This is suitable for small-to-medium apps where CD cycle cost is negligible. For large trees or high async event frequency, the full-tree walk becomes a bottleneck.
OnPush Strategy
A component decorated with ChangeDetectionStrategy.OnPush instructs Angular to skip its subtree during the global CD walk unless one of four triggers fires:
- A new
@Input()reference is received (reference equality check —===) - An event fires in the component or its children
ChangeDetectorRef.markForCheck()is called explicitly- An async pipe emits a new value
OnPush works well with immutable data patterns (NgRx, RTK reducers, spread-new-reference) because reference equality is the CD trigger. Mutating objects in place breaks OnPush silently.
Zoneless Signals Path
provideZonelessChangeDetection() — stable since Angular v20.2, default in Angular v21+ — removes zone.js from the CD loop entirely. Template reads of signals establish reactive subscriptions at render time. When a signal's value changes, Angular marks only the affected component dirty and schedules a micro-task to patch its DOM bindings.
This is the narrowest possible CD scope: O(1) per signal change regardless of component tree size.
TS Example: Three CD Strategies Side by Side
// Source: angular.dev/guide/signals + angular.dev/api/core/provideZonelessChangeDetection
// Three CD strategies side by side
import {
Component, Input, signal, computed,
ChangeDetectionStrategy, ChangeDetectorRef
} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
// Strategy 1: zone.js default — full-tree check on every async event
// No annotation needed; zone.js handles this automatically
// (inefficient for large component trees with high async event frequency)
@Component({
selector: 'app-default',
template: `<p>{{ data.value }}</p>`
})
export class DefaultComponent {
@Input() data!: { value: string };
// data.value = 'mutated' triggers CD even if reference unchanged — zone.js fires on click
}
// Strategy 2: OnPush — skip subtree unless input reference or event changes
@Component({
selector: 'app-on-push',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ data.value }}</p>`
})
export class OnPushComponent {
@Input() data!: { value: string };
// data.value = 'mutated' (same reference) — CD skipped silently
// Must produce new reference: this.data = { ...this.data, value: 'new' }
// or call this.cdRef.markForCheck() for programmatic updates
constructor(private cdRef: ChangeDetectorRef) {}
}
// Strategy 3: Zoneless Signals — narrowest scope; no zone.js
// app.config.ts:
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()]
});
@Component({
selector: 'app-signal',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+</button>
`
})
export class SignalComponent {
readonly count = signal(0);
readonly double = computed(() => this.count() * 2);
increment(): void {
this.count.update(v => v + 1);
// Angular marks only this component dirty — no zone.js, no global tree walk
}
}Engine 3: Fine-Grained Reactivity (Solid / Svelte)
No Virtual DOM. No change detection tree walk. The compiler (Solid's Babel transform, Svelte's compiler) turns JSX/templates into direct DOM mutation calls. A reactive graph tracks which DOM nodes depend on which reactive primitives. When a signal changes, only the exact DOM node bound to that signal updates — O(1) per affected binding, regardless of component count.
This model trades framework ecosystem (React's component library, Angular's enterprise tooling) for maximum rendering throughput. The performance ceiling is higher than Virtual DOM engines; the ecosystem and hiring pool is smaller.
Solid.js Reactive Graph
Solid.js uses three reactive primitives:
createSignal— reactive primitive; returns a getter/setter pair. Reading the getter inside a tracking scope registers a dependency.createMemo— memoised derived value; re-evaluates lazily only when upstream signals change.createEffect— side effect with automatic dependency tracking; re-runs when any signal read inside it changes.
Solid compiles JSX to document.createElement and direct DOM mutation calls — not to React.createElement. There is no vDOM snapshot. The compiled output is approximately: create DOM node, subscribe the node's text content to the signal, call .nodeValue = signal() on signal change.
Svelte 5 Runes
Svelte 5 replaces Svelte 4's implicit compile-time reactivity (let x = 0 + $: labels) with explicit runes — language-level declarations that work both inside and outside .svelte files:
$state— declares reactive state (replacesletwith implicit reactivity from Svelte 4)$derived— memoised computation from$state(replaces$:reactive labels)$effect— side effect with automatic runtime dependency tracking (replacesafterUpdate)
Svelte 5 runes provide runtime dependency tracking, making reactivity predictable and portable. The compiler still outputs targeted DOM mutations — no runtime diffing.
TS Example: Solid.js Reactive List
// Source: docs.solidjs.com/concepts/signals
// Solid JSX — compiles to direct DOM calls, not React.createElement
import { createSignal, createMemo, createEffect, For } from 'solid-js';
const [items, setItems] = createSignal<string[]>([]);
const count = createMemo(() => items().length); // memoised; re-evaluates lazily
createEffect(() => {
// Runs only when items() changes — subscribes to items at call time
document.title = `Cart: ${count()} items`;
});
// JSX compiles to direct DOM calls — no vDOM, no diffing
// <For> is a Solid control-flow component that maps items without key overhead
function Cart() {
return (
<div>
<p>Items: {count()}</p>
<For each={items()}>
{(item) => <li>{item}</li>}
</For>
</div>
);
}
// Compiled output is approximately:
// const p = document.createElement('p');
// const text = document.createTextNode(count().toString());
// p.appendChild(text);
// createEffect(() => { text.nodeValue = count().toString(); });
// No Virtual DOM diffing occurs — the DOM node is updated directlySvelte 5 runes — Counter component:
<!-- Source: svelte.dev/blog/runes + svelte.dev/docs/svelte/$derived -->
<script lang="ts">
let count = $state(0); // reactive primitive
let double = $derived(count * 2); // memoised derivation; re-evaluates lazily
$effect(() => {
// Runs when count or double changes; auto-tracks dependencies at runtime
console.log(`count: ${count}, double: ${double}`);
});
</script>
<button onclick={() => count++}>
Count: {count} — Double: {double}
</button>Common Pitfalls
Pitfall 1: Index-as-Key in React Lists
What goes wrong: items.map((item, i) => <Component key={i} {...item} />). When the list is reordered or an item is inserted at the front, React maps component state to the wrong item. Index-0 still receives the first item's state regardless of which data item moved there — form inputs show the wrong values, controlled components display stale data.
Why it happens: Index keys produce stable numbers (0, 1, 2...) that satisfy React's "missing key" warning without conveying data identity. React has no way to distinguish "item moved" from "item replaced" when keys are positional.
How to avoid: Always use a stable, unique data-derived key: key={item.id}. Only use index keys for truly static, never-reordered lists where items have no identity.
Warning signs: Form inputs losing their values when list items are reordered. State appearing on the wrong item after a filter changes. Controlled inputs flickering on sort.
Pitfall 2: Angular OnPush With Mutable Objects
What goes wrong: A developer adds OnPush to a component but mutates an @Input() array in-place: this.items.push(newItem). The reference does not change, so Angular's OnPush check (===) sees no change and the component never re-renders.
Why it happens: OnPush uses reference equality. Object mutation preserves the reference. The data changed but Angular has no signal of it — === returns true before and after the mutation.
How to avoid: Always produce new references for @Input() data: this.items = [...this.items, newItem]. Use immutable update patterns (spread, Array.with(), Array.filter()) or NgRx/RTK reducers that always return new references.
Warning signs: OnPush component not re-rendering when data is visibly changed. ChangeDetectorRef.markForCheck() calls scattered as workarounds. Nested component trees where inner nodes show stale data.
Pitfall 3: Zone.js + Signals Without Removing zone.js
What goes wrong: A developer migrates to Angular Signals and assumes zone.js is no longer running. Zone.js is still present and still triggers full-tree CD on every async event. Signals do narrow the CD scope for signal-reading components, but the zone.js global cycle still fires. The expected performance improvement is not realised.
Why it happens: Zone.js is included in Angular's default polyfills array in angular.json. Adding provideZonelessChangeDetection() to providers is not sufficient alone — zone.js must also be removed from polyfills.
How to avoid: Two steps required: (1) Add provideZonelessChangeDetection() to app.config.ts providers. (2) Remove zone.js from polyfills in angular.json. Verify using Angular DevTools — no full-tree CD cycles should appear after a button click.
Warning signs: Angular DevTools showing full-tree CD cycles after migrating to Signals. zone.js still present in bundle analysis. Performance profiling showing O(n) CD work despite Signals migration.
Pitfall 4: Solid.js Reactive Access Outside Tracking Scope
What goes wrong: A developer reads a signal in a non-tracked context (outside createEffect, createMemo, or JSX): const value = count(); at module level or at the top of a regular function. This read does not register a dependency. When count changes, the downstream code does not re-run — value remains the initial snapshot.
Why it happens: Solid's reactive graph requires reads to occur inside a tracking scope to register a subscription. Reads outside a tracking scope are intentional one-time snapshots — they are not a bug; they are a deliberate design for non-reactive reads.
How to avoid: Read signals only inside reactive contexts: JSX expressions, createEffect, createMemo, or createRenderEffect. For intentionally non-reactive reads (snapshot at a specific point in time), reading outside a tracking scope is correct. The distinction must be explicit.
Warning signs: Computed value is always the initial signal value even after state changes. createEffect does not re-run when the signal it reads changes. Component does not update despite signal mutation.
Algorithm Connections
Change detection and rendering engines are built on classical algorithm concepts. The connections below bridge rendering engine mechanics to existing vault algorithm notes.
| Algorithm Concept | Connection to Rendering Engines | Link |
|---|---|---|
| Depth-First Search | React's reconciler performs a DFS-style tree walk — beginWork() descends into children (pre-order), completeWork() ascends back (post-order). The fiber tree is traversed depth-first on every reconciliation pass. | Depth-First Search |
| BST Traversal | Tree traversal concepts (pre-order, in-order, post-order) are the algorithmic foundation for Virtual DOM tree walks. React's two-phase reconcile (render + commit) maps to pre-order descent and post-order completion. | BST Traversal |
| Observer Pattern | Angular Signals and RxJS Observable streams are both instances of the Observer pattern — a subject notifies registered observers on state change. Angular's reactive CD is the Observer pattern applied to the component tree: each template is a subscriber; each signal is a subject. | Observer-Pattern |
| Reactive Programming | Fine-grained reactivity in Solid/Svelte is the same push-based dependency graph model as RxJS reactive streams. createSignal is analogous to BehaviorSubject; createEffect is analogous to .subscribe(); createMemo is analogous to .pipe(map(...)). The difference is compile-time vs runtime graph construction. | Reactive-Programming |
Related Concepts
| Concept | Relationship | Link |
|---|---|---|
| State Management Patterns | Rendering engine choice is coupled to state model — signals require a fine-grained reactive engine to propagate efficiently. A Redux store dispatching to an Angular zone.js app is correct; a Redux store in a Solid app wastes fine-grained reactivity by re-rendering on every dispatch. | State-Management-Patterns |
| Rendering Strategies | Change detection operates within the rendering strategy context. Server-side rendering (SSR) with Angular disables zone.js CD on the server — hydration resumes CD on the client. Next.js Server Components skip React reconciliation entirely for the server portion. | Rendering-Strategies |
| Hydration Patterns | Hydration resumes change detection on the client after SSR. Engine choice directly affects hydration cost: React reconciles the entire component tree during hydration; Angular incremental hydration (@defer) hydrates only the triggered subtrees; Astro islands hydrate independent fine-grained component islands. | Hydration-Patterns |
| Observer Pattern | Signals (signal(), createSignal, $state) and RxJS Observables are both Observer pattern variants applied at different granularities — signal per DOM node vs stream per async sequence. | Observer-Pattern |
Sources
legacy.reactjs.org/docs/reconciliation.html— React reconciliation heuristics, O(n) claim, two-heuristic explanation, key rules, element type rulesreact.dev/reference/react/useTransition— Fiber priority system,startTransition,isPending, interruptibility, priority lanesreact.dev/learn/preserving-and-resetting-state— Element type and key rules for state preservation across reconciliation cyclesangular.dev/guide/signals—signal(),computed(),effect(),untracked(), template reactive dependency registrationangular.dev/guide/zoneless—provideZonelessChangeDetection(), trigger list, zone.js vs zoneless comparison, migration guideangular.dev/api/core/provideZonelessChangeDetection— API reference: stable since v20.2, default in Angular v21+angular.dev/best-practices/skipping-subtrees— OnPush strategy,ChangeDetectorRef.markForCheck(), when to use each strategydocs.solidjs.com/concepts/signals—createSignal,createEffect,createMemo, no-vDOM reactive graph,Forcontrol flowsvelte.dev/blog/runes—$state,$derived,$effect, runtime vs compile-time reactivity comparison, Svelte 5 migration from Svelte 4$:labels- npm registry (2026-03-26) — verified package versions:
react@19.2.4,@angular/core@21.2.6,solid-js@1.9.12,svelte@5.55.0