When Svelte first captured the web development community, it arrived with a bold manifesto:
"Virtual DOM is pure overhead. Svelte runs at build time, converting your components into tiny, highly efficient vanilla JavaScript that surgically updates the DOM."
For five years, developers celebrated Svelte's compile-time reactivity. You did not need useState, dependency arrays, or reconciliation engines. You simply wrote ordinary JavaScript:
<script>
let count = 0;
function increment() {
count += 1;
}
</script>
<button on:click={increment}>
Clicks: {count}
</button>
Under the hood, Svelte 3 and 4 parsed your script, spotted the assignment operator (count += 1), and injected a hidden compiler helper called $$invalidate:
function increment() {
$$invalidate(0, count += 1);
}
Whenever $$invalidate fired, Svelte flagged a bitmask dirty array on the component instance. On the next microtask, it walked through a switch statement in the generated update function and mutated the exact DOM node.
It felt like sorcery. But as applications scaled, that pure compile-time illusion began showing deep architectural cracks.
Reactivity only worked inside .svelte single file components. If you moved business logic into a standalone JavaScript file, the compiler could no longer rewrite your variable assignments. You had to abandon normal variables and reach for custom Svelte stores with manual subscriptions, readable contracts, and awkward boilerplate. Furthermore, tracking deep object mutations required reassigning the root object (user.profile.age = 30; user = user;), causing the entire component to re-evaluate its dirty checks.
With Svelte 5, the core team made an audacious architectural pivot. They walked away from pure compile-time assignment rewriting and adopted Runes: explicit primitives powered by a fine-grained, push-pull runtime signal graph.
Many developers were startled. Was Svelte turning into Solid or Vue? Did Svelte abandon its core philosophy?
The reality is far more fascinating. Svelte 5 did not surrender its compiler advantage; it elevated it. By combining static template hoisting with runtime signals, Svelte 5 achieves fine-grained reactivity without the clunky stores of Svelte 4, and without the Virtual DOM overhead of React.
Here is the mechanical breakdown of how Svelte 5 actually executes beneath your keyboard.
1. The Architectural Shift: Svelte 4 vs Svelte 5
To understand why Runes exist, you must inspect the assembly line of Svelte 4.
In Svelte 4, the compiler treated the entire .svelte component as the atomic boundary of reactivity. When you modified a variable, the component did not know which specific DOM node depended on which specific sub-property. The compiler only knew that something inside the component was dirty.
Here is what Svelte 4 generated for a simple counter:
// Svelte 4 Compiler Output (Conceptual)
function p(ctx, [dirty]) {
// If bit 0 in the dirty bitmask is set:
if (dirty & /*count*/ 1) {
set_data(t1, /*count*/ ctx[0]);
}
}
This model came with three severe limitations:
-
The Component Boundary Trap: If you wanted reactive state shared across five components or held in a TypeScript utility file, you could not use
let count = 0. You had to importwritable(0)fromsvelte/store, wrap updates incount.update(n => n + 1), and use the special auto-subscription syntax ($count) inside templates. -
Coarse-Grained Dirty Checking: Every state change marked the entire component dirty. Even if only one text node changed, Svelte had to execute the component's
p()update function, checking every active bitmask condition. -
Array and Object Mutation Pitfalls: Methods like
array.push(item)did not trigger reactivity because no assignment operator existed for the AST parser to rewrite. Developers had to writearray = [...array, item]purely to appease the compiler.
Svelte 5 solves this by moving reactivity from compile-time AST variable instrumentation to universal runtime signal nodes.
Instead of rewriting count += 1, Svelte 5 provides primitives called Runes ($state, $derived, $effect). These functions do not belong to a preprocessor; they are parsed into universal signal nodes that work identically inside .svelte files and universal .svelte.js or .svelte.ts modules.
2. Under the Hood of Runes: The Push-Pull Signal Graph
When you declare state in Svelte 5:
let count = $state(0);
let double = $derived(count * 2);
You are not creating a simple number. You are registering nodes inside a Directed Acyclic Graph (DAG) of reactive dependencies.
A signal graph consists of three primary node types:
-
Source Nodes (
$state): Hold raw values. When written to, they notify their dependents. -
Computation Nodes (
$derived): Depend on one or more sources or other computations. They recalculate only when their upstream dependencies change. -
Consumer Nodes (
$effect): Side effects and DOM update bindings that subscribe to sources or derivations and run when their inputs change.
Most naive signal implementations use an eager push mechanism: when a source changes, it immediately pushes the new value down every branch of the tree. This approach frequently causes the Diamond Problem, where a node that depends on two derived values is evaluated twice in intermediate, inconsistent states (a glitch).
Svelte 5 avoids this completely using a Push-Pull (or Mark-and-Sweep) algorithm.
The Push Phase (Marking Dirty)
When you write count++, Svelte does not immediately recalculate double or update the DOM. Instead, it enters a synchronous push phase:
- The
$state(count)source node marks itself as dirty. - It walks down its dependency links to
$derived(double). - It does not recompute
double; it simply marksdoublewith a status flag:DIRTY. - It continues downstream to any active
$effectconnected todouble, scheduling the effect into the microtask execution queue.
No application logic runs during the push phase. Only lightweight status flags are toggled.
The Pull Phase (Lazy Evaluation)
When the browser reaches the scheduled microtask or when application code reads double:
- The consumer pulls the value from
$derived(double). - The derivation checks its flag. If it is
DIRTY, it checks its upstream dependency (count). - It computes the new value (
count * 2), caches the result, and clears itsDIRTYflag. - If
doubleis read ten times withoutcountchanging, it returns the cached value in $O(1)$ time with zero recalculation.
This separation guarantees glitch-free execution. If two different derivations depend on the same counter and feed into a single effect, the effect runs exactly once, reading both values after all dirty marks have resolved.
3. The Compiler Pipeline: Turning Templates into Vanilla JS
If Svelte 5 uses runtime signals, what role does the compiler play?
This is where Svelte 5 departs dramatically from signal-based frameworks like SolidJS. SolidJS creates individual reactive wrappers around almost every element. Svelte 5 uses its compiler to perform Static Template Hoisting and Surgical Dynamic Attachment.
Consider this Svelte 5 component:
<script>
let count = $state(0);
let user = $state("Ada");
</script>
<div class="card">
<h1>Welcome, {user}!</h1>
<p>Static description text that never changes.</p>
<button onclick={() => count++}>
Clicks: {count}
</button>
</div>
When you pass this file through svelte/compiler, the compilation passes through four distinct phases:
Phase 1: Parsing and AST Construction
The compiler parses the file into an Abstract Syntax Tree (AST), separating the <script> block, CSS styles, and HTML elements into structured nodes.
Phase 2: Static Analysis
The compiler walks the template tree and identifies which parts of the markup are completely static. In our example:
-
<div class="card">is static. -
<h1>Welcome, </h1>has static tags with one dynamic text slot ({user}). -
<p>Static description...</p>is completely static. -
<button>has a dynamic event listener and one dynamic text slot ({count}).
Phase 3: Template Hoisting ($.template)
Instead of compiling each HTML element into separate JavaScript document creation calls (document.createElement('div')), the compiler stringifies the entire invariant HTML structure into a single static string template:
// Compiled output (conceptual representation)
const root_template = $.template(
`<div class="card">
<h1>Welcome, <!>!</h1>
<p>Static description text that never changes.</p>
<button>Clicks: <!></button>
</div>`
);
Notice the <!> comment markers. These serve as hydration anchors and insertion slots for dynamic expressions.
At application initialization, the browser calls document.createElement('template') and parses this HTML string natively in C++ through the browser engine. When a component instance mounts, Svelte clones this entire pre-parsed subtree using root_template(), which executes a single, hyper-fast document.importNode() clone.
Phase 4: Targeted Effect Subscriptions ($.render_effect)
Next, the compiler generates code that traverses directly to the dynamic slots without inspecting the static nodes:
export default function Component($$anchor) {
let count = $.source(0);
let user = $.source("Ada");
const fragment = root_template();
const div = $.first_child(fragment);
const h1 = $.first_child(div);
const user_text_slot = $.child(h1, 1);
const button = $.sibling($.sibling(h1, 2), 2);
const count_text_slot = $.child(button, 1);
// Bind event listener
button.__click = () => $.set(count, $.get(count) + 1);
// Subscribe dynamic text nodes to signals
$.render_effect(() => {
$.set_text(user_text_slot, $.get(user));
});
$.render_effect(() => {
$.set_text(count_text_slot, $.get(count));
});
$.append($$anchor, fragment);
}
Look closely at what happened.
The static <p> tag is never stored in a variable, never inspected by a reconciliation algorithm, and never diffed.
When count changes, only the second $.render_effect wakes up. It reads the new value and executes count_text_slot.data = new_value. The parent component function does not re-run. No intermediate JavaScript objects are allocated. The update is a direct, surgical $O(1)$ memory write.
4. Virtual DOM vs Direct Signal Mutation: The Real Memory Cost
To truly appreciate this architecture, compare it to how React handles the same counter button.
In React, state belongs to the component fiber. When you call setCount(count + 1):
- React marks the entire component fiber dirty.
- The scheduler re-executes the component function from top to bottom.
- Every JSX expression executes, instantiating fresh Virtual DOM JavaScript objects for the
<div>, the<h1>, the<p>, and the<button>. - The reconciler diffs the newly created virtual tree against the previous virtual tree.
- It discovers that the text node inside
<button>changed, creates a DOM patch, and writes to the real DOM. - The entire abandoned virtual tree is discarded and awaits garbage collection.
In simple apps, this process happens in a fraction of a millisecond. But in large dashboards, data tables, or heavy data-visualization tools with thousands of nodes, allocating and diffing virtual trees generates non-trivial CPU work and persistent garbage collection pressure.
In Svelte 5:
- The component function runs once at mount.
- When
countincrements, zero virtual DOM nodes are instantiated. - Zero diffing algorithms run.
- The signal triggers its subscriber directly:
node.data = count.
Because Svelte does not need a runtime reconciliation engine, the core client runtime weighs only a few kilobytes.
5. Deep Proxy Reactivity vs $state.raw: Performance at Scale
One of the most powerful features of Svelte 5 is deep object reactivity. In Svelte 4, mutating an array or nested object required reassignment gymnastics. In Svelte 5, $state wraps objects in JavaScript Proxy instances:
let settings = $state({
theme: "dark",
user: {
notifications: {
email: true
}
}
});
// Works seamlessly:
settings.user.notifications.email = false;
When you pass an object or array to $state(), Svelte recursively intercepts read and write operations. When you mutate settings.user.notifications.email, the proxy trap catches the write, identifies the exact signal node associated with that property path, and notifies only the effects reading that specific leaf property.
However, deep proxying comes with an engineering tradeoff: memory overhead.
If you fetch a dataset of 50,000 GeoJSON points, financial transactions, or WebGL coordinate buffers and wrap it in $state(), Svelte will recursively proxy every single object and array index in that collection. That proxy creation consumes significant CPU time and multiplies heap memory consumption.
To solve this, Svelte 5 introduced $state.raw.
When to Use $state.raw
$state.raw creates a shallow signal that does not proxy its contents:
// Creates a shallow signal with ZERO proxy wrapping
let bigData = $state.raw(largePayloadFromApi);
// This will NOT trigger reactivity:
bigData[0].selected = true;
// Reassigning the root reference DOES trigger reactivity:
bigData = bigData.map(item =>
item.id === targetId ? { ...item, selected: true } : item
);
Use $state.raw whenever:
- You are managing large, immutable datasets (tables with thousands of rows, API responses).
- You are integrating third-party libraries that hold their own complex class instances (Three.js meshes, Leaflet map objects, Monaco editor instances). Wrapping these in Proxies can break internal identity checks (
this === target). - You only care about updates when replacing the entire reference.
By distinguishing between $state for interactive UI state and $state.raw for large data buffers, you can maintain optimal frame rates and minimal heap footprints.
6. Production Pitfalls: The $effect Trap
Because $effect resembles React's useEffect, developers transitioning to Svelte 5 often carry over dangerous habits.
The most common antipattern is using $effect for state synchronization:
// ❌ ANTIPATTERN: Redundant state synchronization
let count = $state(0);
let double = $state(0);
$effect(() => {
double = count * 2;
});
Why is this dangerous?
- It forces two distinct render cycles. First,
countchanges and renders. Next, the effect runs, mutatesdouble, and triggers a second render pass. - It breaks the signal graph's push-pull guarantees, creating potential infinite loops if dependencies are not strictly isolated.
The idiomatic Svelte 5 approach is to use $derived:
// ✅ IDIOMATIC: Synchronous, lazy derivation
let count = $state(0);
let double = $derived(count * 2);
$derived calculates its value synchronously during the pull phase, costs zero effect scheduler overhead, and guarantees that downstream consumers always see consistent data on the very first render pass.
Reserve $effect strictly for true external side effects:
- Manual DOM manipulation (canvas painting, focusing an input).
- Setting up external event listeners or WebSocket subscriptions.
- Logging analytics or saving state to
localStorage.
7. Universal Reactivity in .svelte.js Modules
Perhaps the most transformative benefit of Svelte 5 is that reactivity is no longer trapped inside component markup.
In Svelte 4, building a reusable reactive state machine across files required custom stores:
// Svelte 4: Store boilerplate in cart.js
import { writable, derived } from 'svelte/store';
export const items = writable([]);
export const total = derived(items, $items =>
$items.reduce((sum, item) => sum + item.price, 0)
);
export function addItem(item) {
items.update(list => [...list, item]);
}
In Svelte 5, you create a standard .svelte.js or .svelte.ts file and write ordinary JavaScript classes or factory functions using Runes:
// Svelte 5: cart.svelte.ts
export class Cart {
items = $state<Item[]>([]);
total = $derived(this.items.reduce((sum, i) => sum + i.price, 0));
add(item: Item) {
this.items.push(item);
}
remove(id: string) {
this.items = this.items.filter(i => i.id !== id);
}
}
export const cart = new Cart();
Any component that imports cart can read cart.total or call cart.add(item) directly. Because Cart uses native signals, components automatically subscribe to only the properties they access. If a component only reads cart.total, it will not re-evaluate when an individual item's thumbnail updates.
Reactivity is now a native language feature of your application architecture rather than a special syntax reserved for template files.
The Mental Shift
The transition from Svelte 4 to Svelte 5 is not an admission that compilers failed. It is the realization that the compiler and the runtime signal graph are strongest when working in harmony.
The compiler excels at static analysis: finding invariant HTML, hoisting templates into pre-parsed DOM clones, and isolating reactive holes at build time.
Runtime signals excel at dynamic dependencies: tracking relationships across module boundaries, managing deep object mutations without reassignment hacks, and executing push-pull updates without dirty bitmask arrays.
By pairing a specialized build-time compiler with a fine-grained signal engine, Svelte 5 delivers what front-end architectures have promised for a decade: the expressive simplicity of vanilla JavaScript variables with the surgical precision of direct DOM updates.
If you have migrated existing production applications to Svelte 5, how has the transition from Svelte 4 stores to Runes shaped your state architecture? Have you encountered cases where $state.raw made a measurable difference in memory consumption? Share your findings and architectural patterns in the comments below.





Top comments (1)
Great deep dive into how Runes and fine-grained signals work under the hood! Moving toward explicit reactivity primitives makes tracking state mutations across boundaries so much more predictable.