Table of Contents (Jump to section)

In 2013, when Facebook open-sourced React, it introduced an architectural concept that transformed the web development industry for more than a decade: the Virtual DOM (VDOM). At the time, directly manipulating real browser DOM nodes via imperative jQuery selectors was notoriously slow, error-prone, and prone to layout thrashing. React promised a radical declarative mental model: treat your UI as a pure mathematical function of state (UI = f(state)). Whenever state changes, re-execute the component, generate a brand-new virtual tree of JavaScript objects in memory, diff it against the previous tree using a heuristic reconciliation algorithm, and calculate the minimal set of real DOM mutations.

For ten years, the Virtual DOM was hailed as an unassailable engineering triumph. But in 2026, as enterprise web applications grew to hundreds of thousands of lines of code and Google replaced First Input Delay (FID) with the rigorous Interaction to Next Paint (INP) Core Web Vital, the fundamental tax of the Virtual DOM has become impossible to ignore: top-down component re-render cascades, massive JavaScript memory allocations, and main-thread reconciliation pauses.

Virtual DOM Reconciliation vs Fine-Grained Reactive Signal Topology

Today, the frontend landscape is undergoing its most consequential architectural realignment since 2015: the triumph of Fine-Grained Reactivity and Signals. Championed initially by SolidJS, adopted natively by Angular, embraced by Vue and Preact, and culminated in the revolutionary release of Svelte 5 (Runes), signals eliminate the Virtual DOM entirely. In this comprehensive guide, we dissect the mathematical differences between VDOM diffing and reactive dependency graphs, analyze Svelte 5 Runes, evaluate the architectural mechanics behind sub-millisecond INP scores, and build a production-grade signal engine from scratch.

---

1. The Hidden Tax of the Virtual DOM

To understand why the frontend industry is moving away from the Virtual DOM, platform engineers must look past high-level marketing claims and analyze what the browser's JavaScript engine (V8, JavaScriptCore, SpiderMonkey) actually does during a state update.

Reactivity Dimension Virtual DOM (React / React Fiber) Fine-Grained Signals (SolidJS / Svelte 5 / Angular)
Granularity of Execution Component-Level: The entire component function re-runs from top to bottom Atomic Expression-Level: Only the exact text node or attribute updates
Reconciliation Cost $O(N)$ Tree Diffing: Walks and compares two parallel object trees $O(1)$ Direct Propagation: Emits update directly down the subscriber graph
Memory & GC Footprint Massive allocation of temporary VNode objects discarded on every render Zero intermediate allocations; persistent closure bindings in DOM nodes
Optimization Burden Manual developer overhead: useMemo, useCallback, React.memo Automatic compiler optimization; zero cognitive memoization tax
Interaction to Next Paint (INP) Vulnerable to main-thread stalls during large subtree diffs (>50ms) Sub-millisecond execution (<2ms); instant frame submission

The VDOM Execution Lifecycle

When a developer calls setCount(prev => prev + 1) in a standard React component tree:

  1. The React scheduler marks the component fiber dirty and enqueues a render phase on the browser's main thread.
  2. The entire JavaScript function representing the component executes again from line 1 to the end. Every variable, internal callback, and child JSX expression is re-instantiated.
  3. React allocates a tree of lightweight JavaScript objects (Virtual DOM nodes) representing the proposed UI state.
  4. The reconciler recursively traverses the previous Virtual DOM tree and the newly generated tree, comparing component types, keys, and props to construct an effect list (the patch).
  5. During the commit phase, React mutates the real browser DOM with the calculated diff.
  6. Finally, the discarded Virtual DOM tree is abandoned in V8 memory, triggering the browser's Garbage Collector (GC) to pause the main thread to reclaim memory blocks.

The Core Paradox: The Virtual DOM was created because manipulating the real DOM is slow. But diffing millions of in-memory JavaScript objects on every keystroke, combined with garbage collector churn, has become slower than simply modifying the exact real DOM node directly.

---

2. How Signals Work: The Reactive Dependency Graph

Signals invert the entire rendering paradigm. Instead of re-running the component function to find out what changed, a signal is a reactive value wrapper that automatically tracks which functions read it and notifies only those exact subscribers when its value mutates.

A signal architecture consists of three fundamental primitives:

  • Signal (State / Source): The raw source of truth holding a value. It provides a getter and a setter. When read inside an active context, it registers the context as a subscriber.
  • Computed (Derived / Memo): A derived reactive value calculated from one or more upstream signals. It caches its result and only re-evaluates when its upstream dependencies mutate.
  • Effect (Subscriber / Sink): A side-effect function that executes automatically whenever any signal it depends upon changes. In modern compiled frameworks, the "effect" is simply a micro-closure that mutates a single real DOM element: element.textContent = signal().

The O(1) Pinpoint Update

In a signal-based framework like SolidJS or Svelte 5, the component function executes exactly once in its entire lifetime—during initial mounting. It creates the DOM nodes, sets up the reactive bindings, and never runs again.

When count.set(count() + 1) is called:

Signal count mutated &rarr; Dependency Graph notifies only DOM TextNode subscriber &rarr; textNode.data = count();

There is no Virtual DOM tree. There is no reconciliation algorithm. There is no component re-execution. The mutation cost is strictly $O(1)$ constant time, executing in microseconds with zero garbage collection overhead.

---

3. Svelte 5 Runes: The Compiler-Driven Frontier

While earlier versions of Svelte used the classic let count = 0 syntax and compile-time assignment instrumentation (count += 1), this syntax broke down when state logic was extracted into external TypeScript utility files or shared stores.

With Svelte 5, the framework completely re-architected its reactivity around Runes—explicit, compiler-guided reactive primitives that work identically inside .svelte components and standalone .svelte.ts modules:

The Core Runes

// 1. $state: Declares deep reactive state
let count = $state(0);
let user = $state({ name: 'Hadi', role: 'Architect' });

// 2. $derived: Declares pure cached derived state (replaces $: assignments)
let double = $derived(count * 2);
let isAuthorized = $derived(user.role === 'Architect');

// 3. $effect: Declares managed side-effects (replaces lifecycle hooks)
$effect(() =&gt; {
    console.log(`The count updated to ${count}`);
    // Optional teardown function
    return () =&gt; console.log('Cleaned up previous effect');
});

// 4. $props: Typesafe, reactive component inputs
let { title, maxLimit = 100 }: { title: string; maxLimit?: number } = $props();

Under the hood, the Svelte 5 compiler transforms $state and $derived into high-performance signal nodes linked in a push-pull reactive graph. Unlike React where extracting logic requires complex custom hooks with fragile dependency arrays ([a, b, c]), Svelte 5 runes allow state to be encapsulated in standard JavaScript classes and imported anywhere across the codebase with zero boilerplate.

---

4. Interaction to Next Paint (INP): The Google Core Web Vital Metric

In March 2024, Google officially retired First Input Delay (FID) as an official Core Web Vital and replaced it with Interaction to Next Paint (INP). FID only measured the delay between a user's first click and when the main thread began processing the event listener. It completely ignored how long the browser took to actually render the resulting UI updates.

INP measures the entire latency lifecycle of every single user interaction (clicks, taps, keystrokes) across the page's lifespan:

INP Phase Physical Activity Virtual DOM Bottleneck Signals Advantage
1. Input Delay Time from user physical tap until event handler executes Main thread frequently blocked by background VDOM reconciliation Main thread is idle; event handler executes in <1ms
2. Processing Time Time spent executing the JavaScript event handler Component tree re-renders, recalculating state across parents Signal updates value in memory in <0.1ms
3. Presentation Delay Time from handler completion until the browser paints pixels Massive layout thrash & VDOM commit diffing (>100ms) Direct pinpoint DOM patch (<2ms); instant frame paint

Google defines a "Good" INP score as under 200 milliseconds. Applications exceeding 500 milliseconds suffer direct Google SEO ranking penalties. In high-density dashboards, e-commerce filter grids, and interactive data grids, Virtual DOM component re-renders frequently trigger 150ms to 350ms main-thread freezes. Fine-grained signal architectures effortlessly achieve INP scores of under 15 milliseconds on low-end mobile hardware.

---

5. Building a Micro Signal Engine From Scratch (50 Lines of Code)

To truly understand how signals operate without magic, let us construct a complete, functional reactive signal engine in pure modern TypeScript. The entire architecture relies on an active effect context stack and subscriber sets:

// Micro Reactive Signal Engine (50 Lines of Pure TypeScript)

type Subscriber = () =&gt; void;

// Global tracking stack for current active effect
let activeSubscriber: Subscriber | null = null;
const contextStack: (Subscriber | null)[] = [];

export interface Signal&lt;T&gt; {
    (): T;                  // Getter
    (nextValue: T): void;   // Setter
}

export function createSignal&lt;T&gt;(initialValue: T): Signal&lt;T&gt; {
    let value = initialValue;
    const subscribers = new Set&lt;Subscriber&gt;();

    function accessor(nextValue?: T): T {
        if (arguments.length &gt; 0) {
            // SET: Value has mutated &rarr; Notify all registered subscribers
            if (value !== nextValue) {
                value = nextValue!;
                // Clone set to prevent infinite loop if subscriber triggers new write
                const toRun = Array.from(subscribers);
                toRun.forEach(sub =&gt; sub());
            }
        } else {
            // GET: Track active subscriber into this signal's dependency set
            if (activeSubscriber &amp;&amp; !subscribers.has(activeSubscriber)) {
                subscribers.add(activeSubscriber);
            }
        }
        return value;
    }

    return accessor as Signal&lt;T&gt;;
}

export function createEffect(fn: Subscriber): void {
    const execute = () =&gt; {
        contextStack.push(activeSubscriber);
        activeSubscriber = execute;
        try {
            fn(); // Executing fn() will automatically trigger signal GET accessors
        } finally {
            activeSubscriber = contextStack.pop() || null;
        }
    };

    execute(); // Initial run to discover dependencies automatically
}

export function createDerived&lt;T&gt;(fn: () =&gt; T): () =&gt; T {
    let cachedValue: T;
    let isDirty = true;

    // Derived value subscribes to upstream signals via an effect
    createEffect(() =&gt; {
        isDirty = true;
    });

    return () =&gt; {
        if (isDirty) {
            cachedValue = fn();
            isDirty = false;
        }
        return cachedValue;
    };
}

Testing the Micro Signal Engine

// Usage Example:
const count = createSignal(0);
const multiplier = createSignal(10);
const total = createDerived(() =&gt; count() * multiplier());

// DOM Binding Effect
createEffect(() =&gt; {
    document.getElementById('display')!.textContent = `Total: ${total()}`;
});

// Mutating a signal triggers the DOM effect automatically with zero VDOM diffing:
count(5);       // Output: 'Total: 50'
multiplier(2);  // Output: 'Total: 10'
---

6. Architectural Comparison Matrix

Framework / Paradigm Reactivity Engine DOM Update Mechanism Component Function Lifecycle Target Best Fit
React 19 State Hooks (useState) & React Compiler Virtual DOM Fiber reconciliation Re-executes on every state change Massive existing ecosystem; complex cross-platform setups
Svelte 5 Runes ($state, $derived) Direct compiled surgical DOM mutations Executes once on mount; never re-runs High-performance web apps, fast bundle sizes, elegant DX
SolidJS Fine-Grained Signals (createSignal) Pure reactive graph; zero Virtual DOM Executes once on mount Maximum raw client-side benchmark speed; data-intensive dashboards
Angular 19 Angular Signals (signal(), computed()) Zoneless template reactive bindings Eliminates Zone.js dirty checking entirely Large-scale enterprise platforms requiring strict typesafety
---

Frequently Asked Questions (FAQ)

Why did React choose not to adopt fine-grained signals like Svelte and SolidJS?

React's core architectural philosophy is built on pure immutability and the mental model of snapshots (rendering the whole component as a function of current props and state). Adopting fine-grained signals would require abandoning the component re-render lifecycle and breaking backwards compatibility with millions of existing npm packages and class/hook libraries. Instead, React developed the React Compiler (React Forget) to auto-memoize JSX expressions while retaining the Virtual DOM.

Do signals work outside of user-interface components?

Yes. Because signals are decoupled from the rendering engine, they can be utilized in standalone TypeScript files, Node.js backend services, game loops, Web Workers, and state stores. In Svelte 5, runes can be declared in any .svelte.ts file and shared globally across independent application domains without requiring Redux or Context providers.

How do signals improve Google Core Web Vitals (INP)?

Interaction to Next Paint (INP) measures how long the main thread takes to display visual updates following a user interaction. Under Virtual DOM frameworks, a state update triggers top-down component execution and tree diffing, locking the main thread for 50ms to 200ms. Signals bypass component re-renders completely, directly updating the single target DOM node in less than 2 milliseconds and allowing the browser to submit the visual frame instantly.

What is the difference between a Signal and an RxJS Observable?

An RxJS Observable is a stream of values over time that requires manual subscription management (and explicit unsubscriptions to prevent memory leaks). A Signal is a state container that holds a synchronous current value. Signals handle dependency tracking and unsubscription automatically through dynamic execution stacks, eliminating subscription leaks and complex operators like switchMap or takeUntil for standard UI reactivity.