The Reactivity Engine: Sources, Deriveds, Effects, and the Batch Scheduler
Prerequisites
- ›Article 1: Architecture and Codebase Map
- ›Article 2: Compiler Pipeline (understanding compiled output format)
- ›Reactive programming basics (signals, dependency tracking)
- ›Bitwise operations (flags, masks, &, |, ^)
- ›ES Proxy handler traps
The Reactivity Engine: Sources, Deriveds, Effects, and the Batch Scheduler
Svelte 5's reactivity engine is a pull-based signal system. When a source value changes, it doesn't immediately propagate — it marks dependent reactions as potentially dirty, and they re-evaluate lazily when the scheduler gets to them. This design enables derived values to skip unnecessary recomputation and keeps update batching efficient. Let's look at how it works, starting from the data structures.
The Signal Graph: Sources, Deriveds, and Effects
There are three reactive primitives, all defined as plain JavaScript objects with a shared shape:
Sources are the entry points — the leaves of the reactive graph. Created by $.state(), a source holds a value and tracks which reactions depend on it. From sources.js:
export function source(v, stack) {
var signal = {
f: 0, // flags
v, // value
reactions: null, // dependents
equals, // equality function
rv: 0, // read version
wv: 0 // write version
};
return signal;
}
Deriveds are lazy computations — both readers and writers in the graph. Created by $.derived(), they have the same fields as sources plus a fn (the computation function), deps (upstream dependencies), and parent/child pointers. From deriveds.js:
export function derived(fn) {
var flags = DERIVED | DIRTY;
// ...creates an object with fn, deps, parent, first, last, etc.
}
Effects are side-effect functions that form a tree. Created by create_effect() in effects.js, they include DOM node references and tree pointers:
var effect = {
ctx: component_context,
deps: null,
nodes: null, // { start, end } DOM anchors
f: type | DIRTY | CONNECTED,
first: null, // first child effect
fn, // the effect function
last: null, // last child effect
next: null, // next sibling effect
parent, // parent effect
prev: null, // previous sibling effect
teardown: null, // cleanup function
wv: 0,
ac: null // AbortController
};
graph TD
subgraph Sources
S1["source { v: 0, reactions: [...] }"]
S2["source { v: 'hello', reactions: [...] }"]
end
subgraph Deriveds
D1["derived { fn, deps: [S1, S2], reactions: [...] }"]
end
subgraph Effects
E1["root_effect { first → E2 }"]
E2["render_effect { deps: [D1], parent → E1 }"]
end
S1 -->|"reactions"| D1
S2 -->|"reactions"| D1
D1 -->|"reactions"| E2
E1 -->|"first"| E2
The Bitwise Flag System
Every reactive node has a .f (flags) integer field. Status checks use bitwise operations, which are significantly faster than property lookups or string comparisons. The flags are defined in constants.js:
| Flag | Bit | Purpose |
|---|---|---|
DERIVED |
1 << 1 |
Node is a derived computation |
EFFECT |
1 << 2 |
Node is a user effect |
RENDER_EFFECT |
1 << 3 |
Node is a render effect (synchronous) |
BLOCK_EFFECT |
1 << 4 |
Effect that preserves children across reruns |
BRANCH_EFFECT |
1 << 5 |
Effect representing a conditional branch |
ROOT_EFFECT |
1 << 6 |
Top-level effect (mount point) |
BOUNDARY_EFFECT |
1 << 7 |
Error/suspense boundary |
CONNECTED |
1 << 9 |
Connected to the effect tree |
CLEAN |
1 << 10 |
Value is up-to-date |
DIRTY |
1 << 11 |
Value needs recomputation |
MAYBE_DIRTY |
1 << 12 |
Upstream might have changed |
INERT |
1 << 13 |
Paused (offscreen, transitioning out) |
DESTROYED |
1 << 14 |
Permanently removed |
Checking a flag is a single bitwise AND: (reaction.f & DIRTY) !== 0. Setting uses OR: reaction.f |= DIRTY. Clearing uses AND-NOT: reaction.f &= ~DIRTY. These operations compile to single CPU instructions.
Tip: When debugging the reactivity engine, you can decode a
.fvalue by checking it against the flag constants. For example,f = 2054in binary is100000000110, which isDIRTY | CONNECTED | DERIVED.
Dependency Tracking: get() and the Reaction Context
The get() function is the heart of dependency tracking. Every time a reactive value is read — whether it's a source, derived, or proxy property — get() is called. It checks if there's an active_reaction (the currently executing effect or derived) and, if so, registers a dependency:
export function get(signal) {
if (active_reaction !== null && !untracking) {
// ...register dependency
if (signal.rv < read_version) {
signal.rv = read_version;
if (new_deps === null && deps !== null && deps[skipped_deps] === signal) {
skipped_deps++;
} else if (new_deps === null) {
new_deps = [signal];
} else {
new_deps.push(signal);
}
}
}
}
The rv (read version) and wv (write version) fields are an optimization to avoid duplicate dependency registrations. Each reaction execution increments a global read_version. If a signal's rv matches the current read_version, it was already registered in this execution cycle and can be skipped.
The skipped_deps optimization is particularly clever: if an effect reads the same dependencies in the same order as the previous execution, it increments skipped_deps instead of allocating a new array. Only when the dependency order diverges does it start building new_deps. This means most re-runs produce zero garbage.
sequenceDiagram
participant Effect as Active Effect
participant Get as get()
participant Source as Source Signal
Effect->>Get: read source.value
Get->>Get: active_reaction !== null?
Get->>Get: signal.rv < read_version?
Get->>Get: deps[skipped_deps] === signal?
alt Same order as last run
Get->>Get: skipped_deps++
else New dependency
Get->>Get: new_deps.push(signal)
end
Get-->>Effect: return signal.v
The module-level variables active_reaction, active_effect, untracking, and current_sources at runtime.js#L73-L95 form the execution context. This is the same module-level state pattern we saw in the compiler (Article 2), used here for the same reason: performance in hot loops.
Executing Reactions: update_reaction()
The update_reaction() function is the core execution loop. It runs a reaction's function while carefully managing the dependency tracking context:
export function update_reaction(reaction) {
// Save all context variables
var previous_deps = new_deps;
var previous_reaction = active_reaction;
// ... 6 more saves
// Set up new context
new_deps = null;
skipped_deps = 0;
active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null;
set_component_context(reaction.ctx);
try {
var result = reaction.fn();
// Reconcile dependency arrays...
} finally {
// Restore all context variables
}
}
The save-and-restore pattern ensures that reactions can be nested (effects inside effects, deriveds evaluated during effect execution) without corrupting the tracking context. After the function runs, it reconciles the dependency list — removing reactions from signals that are no longer dependencies and adding reactions to newly discovered ones.
flowchart TD
Start["update_reaction(reaction)"] --> Save["Save 8 context variables"]
Save --> Setup["Set active_reaction,<br/>reset new_deps, skipped_deps"]
Setup --> Run["Execute reaction.fn()"]
Run --> Reconcile{"new_deps !== null?"}
Reconcile -->|yes| Update["Update deps array,<br/>register reactions on new deps"]
Reconcile -->|no| Trim["Trim deps to skipped_deps length"]
Update --> Restore["Restore 8 context variables"]
Trim --> Restore
The Effect Tree Structure
Effects don't live in a flat list — they form a tree via parent-child linked lists. The first/last pointers connect an effect to its children. The prev/next pointers create a sibling chain. The parent pointer links back up.
This tree structure mirrors the component tree. A component's root effect contains render effects for each template region. Control flow blocks ({#if}, {#each}) create branch effects as children. The different effect types define the tree's behavior:
ROOT_EFFECT— created bymount(), owns the entire componentRENDER_EFFECT— synchronous effects for DOM updates, run before user effectsEFFECT— user effects ($effect), run after DOM updatesBLOCK_EFFECT— wraps control flow; preserves children across rerunsBRANCH_EFFECT— a conditional branch within a block (one arm of an{#if})
graph TD
Root["ROOT_EFFECT<br/>(component mount)"]
Root --> RE1["RENDER_EFFECT<br/>(template setup)"]
Root --> Block["BLOCK_EFFECT<br/>({#if condition})"]
Block --> Branch1["BRANCH_EFFECT<br/>(true branch)"]
Branch1 --> RE2["RENDER_EFFECT<br/>(update text)"]
Block --> Branch2["BRANCH_EFFECT<br/>(false branch)"]
Root --> UE["EFFECT<br/>($effect user code)"]
When a block's condition changes, the old BRANCH_EFFECT is paused (allowing outro transitions to run) and a new branch is created. The block effect itself is never destroyed — it just switches which branch is active.
Dirty Checking: The CLEAN/MAYBE_DIRTY/DIRTY State Machine
The three status flags — CLEAN, MAYBE_DIRTY, and DIRTY — form a state machine that enables lazy evaluation. The is_dirty() function determines whether a reaction needs re-execution:
DIRTY— a direct dependency changed. Definitely needs re-execution.MAYBE_DIRTY— an upstream derived might have changed. Check dependencies first.CLEAN— nothing changed. Skip.
When a source is written to, it marks its direct dependents as DIRTY (if they're effects) or propagates MAYBE_DIRTY upward (if the dependent is a derived). This is the "push" phase. The "pull" phase happens in is_dirty():
export function is_dirty(reaction) {
if ((flags & DIRTY) !== 0) return true;
if ((flags & MAYBE_DIRTY) !== 0) {
var dependencies = reaction.deps;
for (var i = 0; i < dependencies.length; i++) {
var dependency = dependencies[i];
if (is_dirty(dependency)) {
update_derived(dependency);
}
if (dependency.wv > reaction.wv) {
return true;
}
}
set_signal_status(reaction, CLEAN);
}
return false;
}
For MAYBE_DIRTY reactions, it recursively checks each dependency. If a derived dependency is itself dirty, it's re-evaluated. Then the write version (wv) is compared. If no dependency has a newer wv than the reaction's wv, the reaction is clean — no work needed.
This is the key performance insight: a derived value that depends on another derived doesn't recompute unless the upstream value actually changed. The MAYBE_DIRTY state defers evaluation until someone actually needs the value.
The Batch Scheduler
The Batch class orchestrates how reactive updates are processed. When a source is written to, it calls Batch.ensure() to get or create the current batch, then schedules affected effects.
A batch collects signal changes and defers processing to a microtask. This means multiple synchronous writes are batched into a single update:
count = 1; // schedules batch
count = 2; // same batch
count = 3; // same batch
// microtask fires: process all changes once
The batch maintains several data structures:
current— aMap<Value, [any, boolean]>of current signal values in this batchprevious— aMap<Value, any>of values before the batch started#roots— root effects that need flushing#commit_callbacks— DOM operations deferred until commit
Processing walks the effect tree top-down, collecting dirty effects. Render effects (synchronous DOM updates) run first, then user effects. This ordering ensures the DOM is consistent before any user $effect code executes.
The batch system also supports forks — parallel batches for concurrent rendering. A fork has its own set of signal values and can be committed or discarded independently. This enables the experimental async rendering mode where new branches can render in the background while the current UI remains visible.
Tip:
flushSync()forces all pending batches to process immediately. It's useful in tests and when you need the DOM to be up-to-date before reading from it.
Deep Reactivity via ES Proxy
When you write let obj = $state({ x: 1 }), the compiler generates $.proxy(...) to wrap the object in an ES Proxy. The proxy() function only wraps plain objects and arrays — class instances, DOM elements, and other exotic objects pass through unchanged:
export function proxy(value) {
if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
return value;
}
const prototype = get_prototype_of(value);
if (prototype !== object_prototype && prototype !== array_prototype) {
return value;
}
// Create proxy with lazy source creation...
}
The proxy creates source signals lazily — one per property, allocated on first access. When you read obj.x, the proxy's get trap calls get() on the corresponding source signal, establishing a dependency. When you write obj.x = 2, the set trap calls set() on that source, triggering reactivity.
This lazy approach means an object with 100 properties doesn't create 100 sources upfront — only the properties that are actually read in reactive contexts get tracked. A version source is bumped on structural changes (adding/deleting properties, array mutations) to handle iteration and Object.keys().
flowchart TD
Proxy["ES Proxy for { x: 1, y: 2 }"]
Proxy -->|"get trap: obj.x"| GetX["sources.get('x') ?? create source"]
GetX --> Signal["get(source) → registers dependency"]
Proxy -->|"set trap: obj.x = 3"| SetX["set(source, 3) → triggers reactivity"]
Proxy -->|"ownKeys trap"| Version["get(version) → tracks structural changes"]
What's Next
The reactivity engine provides the "what to update" — but how does Svelte actually create and update the DOM? In the next article, we'll explore the template instantiation system that uses cloneNode() for fast DOM creation, the control flow block implementations ({#if}, {#each}), and the hydration protocol that lets client-side Svelte pick up where server-rendered HTML left off.