Ripple Runtime: Updates & Performance
Part 4 of the Ripple internals deep dive series.
Phase 3: User Interaction - Incrementing Count
Now that the component is mounted and all dependencies are registered, let's see what happens when the user clicks "Increment". This will change count from 0 to 1, triggering the update cycle that demonstrates how Ripple's reactivity system propagates changes.
Step 3.1: Event Handler Execution
User Action: Clicks button → @count++ executes
Compiled Code:
onClick: () => _$_.set(count, _$_.get(count) + 1)
Execution Flow:
Step 1: Read current value
_$_.get(count) // Returns 0
Context:
tracking = false // Not in tracking context (event handler)
active_reaction = null
Event handlers don't track dependencies because they're not reactive blocks. They simply read and write values without creating dependency relationships. This is important - event handlers are outside the reactive system, so they don't create dependencies when reading values.
Now let's see what happens when we actually update the value.
Step 2: Update value
_$_.set(count, 1) // Set count to 1
Algorithm: set(count, 1)
FUNCTION set(count, 1):
// Step 1: Check if mutations allowed
IF NOT is_mutating_allowed: // false (mutations allowed)
THROW Error('Assignments not allowed during computed evaluation')
// Step 2: Get old value
old_value = count.__v // 0
// Step 3: Early exit if value unchanged (optimization!)
IF 1 === 0: // false - value changed
RETURN
// Step 4: Get associated block
tracked_block = count.b // root_block (or component_ctx)
// Step 5: Store old value for teardown (if needed)
IF (tracked_block.f & CONTAINS_TEARDOWN) !== 0: // false
// Skip (no teardown needed)
// Step 6: Apply custom setter (none)
IF count.a.set IS NOT undefined: // false
value = untrack(() => count.a.set(1, 0))
// Step 7: Update value and clock (THE KEY!)
count.__v = 1 // Value updated
count.c = increment_clock() // Clock incremented!
// Step 8: Schedule update to associated block
schedule_update(count.b) // This triggers the update cycle!
END FUNCTION
Algorithm: increment_clock()
FUNCTION increment_clock():
clock = clock + 1 // Global clock: 0 → 1
RETURN clock // Returns 1
END FUNCTION
State Changes:
Before:
count = {
__v: 0, // Old value
c: 0, // Old clock
b: root_block,
// ...
}
clock = 0 // Global clock
After:
count = {
__v: 1, // Updated value
c: 1, // Clock incremented!
b: root_block,
// ...
}
clock = 1 // Global clock incremented
Key Point: The clock increment (count.c = 1) is crucial. This is how we detect changes later. When we check if a block is dirty, we compare count.c (current) with dependency.c (stored). Since 1 > 0, we know the value changed.
Clock-Based Change Detection:

Now that the value is updated and the clock is incremented, we need to schedule an update to the blocks that depend on this value.
Step 3.2: Update Scheduling - Queuing the Update
Now that count has changed, we need to tell the system "hey, something changed, please update!" This is done through the scheduling system.
Code:
schedule_update(count.b) // count.b = root_block
Algorithm: schedule_update(root_block)
FUNCTION schedule_update(root_block):
// Step 1: Queue microtask (batches updates)
IF scheduler_mode === FLUSH_MICROTASK: // true
queue_microtask() // Schedules flush_microtasks()
// Step 2: Walk up block tree, marking blocks
current = root_block
WHILE current IS NOT NULL:
flags = current.f // ROOT_BLOCK
// Optimization: Already marked? Skip
IF (flags & CONTAINS_UPDATE) !== 0: // false
RETURN // Already scheduled
// Mark block as containing update
current.f = flags | CONTAINS_UPDATE
// Stop at root block (top of tree)
IF (flags & ROOT_BLOCK) !== 0: // true
BREAK
current = current.p // Would go to parent (none for root)
// Step 3: Add root block to update queue
queued_root_blocks.push(root_block)
END FUNCTION
State Changes:
Before:
root_block.f = ROOT_BLOCK
queued_root_blocks = []
is_micro_task_queued = false
After:
root_block.f = ROOT_BLOCK | CONTAINS_UPDATE // Marked for update
queued_root_blocks = [root_block] // Queued
is_micro_task_queued = true // Microtask scheduled
Algorithm: queue_microtask()
FUNCTION queue_microtask():
// Only queue once (batching optimization)
IF NOT is_micro_task_queued: // true
is_micro_task_queued = true
queueMicrotask(flush_microtasks) // ← Browser API: runs after current task
END FUNCTION
Why Microtask Batching?
Instead of updating immediately, Ripple batches updates using queueMicrotask(). This means multiple rapid changes result in a single update cycle, improving performance by reducing DOM updates. Updates happen after the current code finishes, providing predictable timing.
Example:
@count++ // Schedules update
@count++ // Already queued, no new microtask
@count++ // Already queued, no new microtask
// All three changes processed in single flush!
Now that the update is scheduled, let's see what happens when the microtask executes and flushes the updates.
Step 3.3: Microtask Flush
When the microtask executes:
Algorithm: flush_microtasks()
FUNCTION flush_microtasks():
is_micro_task_queued = false
// Execute queued microtasks (none)
IF queued_microtasks.length > 0: // false
// ...
// Safety check
IF flush_count > 1001: // false
RETURN
// Flush root blocks
previous_queued_root_blocks = queued_root_blocks // [root_block]
queued_root_blocks = []
flush_queued_root_blocks(previous_queued_root_blocks)
// Reset flush count
IF NOT is_micro_task_queued: // true
flush_count = 0
old_values.clear()
END FUNCTION
Algorithm: flush_queued_root_blocks([root_block])
FUNCTION flush_queued_root_blocks([root_block]):
FOR EACH root_block IN [root_block]:
flush_updates(root_block)
END FUNCTION
Step 3.4: Update Flush
Algorithm: flush_updates(root_block)
FUNCTION flush_updates(root_block):
current = root_block
containing_update = null
effects = []
// Depth-first traversal
WHILE current IS NOT NULL:
flags = current.f
// Track containing update block
IF (flags & CONTAINS_UPDATE) !== 0: // true for root_block
current.f = flags & ~CONTAINS_UPDATE // Clear flag
containing_update = root_block
// Execute block if not paused and inside update boundary
IF (flags & PAUSED) === 0 AND containing_update IS NOT NULL: // true
IF (flags & EFFECT_BLOCK) !== 0: // false
effects.push(current)
ELSE:
TRY:
// Check if dirty
IF is_block_dirty(current): // Check this!
run_block(current)
CATCH error:
handle_error(error, current)
// Traverse to first child
child = current.first // count_render_block
IF child IS NOT NULL:
current = child
CONTINUE
// Move to next sibling or parent
parent = current.p
current = current.next
// Walk up tree if no sibling
WHILE current IS NULL AND parent IS NOT NULL:
IF parent === containing_update:
containing_update = null
current = parent.next
parent = parent.p
// Execute effects
FOR EACH effect IN effects:
// ...
END FUNCTION
For root_block: is_block_dirty(root_block)
FUNCTION is_block_dirty(root_block):
flags = root_block.f
// Root blocks always execute
IF (flags & (ROOT_BLOCK | BRANCH_BLOCK)) !== 0: // true
RETURN false // Always dirty (always execute)
END FUNCTION
Root blocks and branch blocks always execute during flush_updates because they're structural elements that need to process their children. The is_block_dirty function returns false for these block types, but flush_updates still executes them because they serve as entry points for the traversal. The is_block_dirty check is primarily used for RENDER_BLOCK and EFFECT_BLOCK types to determine if they need re-execution.
For count_render_block: is_block_dirty(count_render_block)
FUNCTION is_block_dirty(count_render_block):
flags = count_render_block.f
// Not root or branch
IF (flags & (ROOT_BLOCK | BRANCH_BLOCK)) !== 0: // false
RETURN false
// Has run before
IF (flags & BLOCK_HAS_RUN) === 0: // false
block.f = flags | BLOCK_HAS_RUN
RETURN true
// Check dependencies
RETURN is_tracking_dirty(count_render_block.d)
END FUNCTION
Algorithm: is_tracking_dirty(count_render_block.d)
FUNCTION is_tracking_dirty(count_render_block.d):
dependency_chain = count_render_block.d // { c: 0, t: count, n: null }
IF dependency_chain IS NULL: // false
RETURN false
current = dependency_chain
WHILE current IS NOT NULL:
tracked = current.t // count
// Not derived
IF (tracked.f & DERIVED) !== 0: // false
update_derived(tracked)
// Check clock: count.c (1) > dependency.c (0)?
IF tracked.c > current.c: // 1 > 0 = true
RETURN true // DIRTY!
current = current.n // null
RETURN false
END FUNCTION
Result: is_block_dirty(count_render_block) returns true - block is dirty!
Execution: run_block(count_render_block)
FUNCTION run_block(count_render_block):
// Set context
active_block = count_render_block
active_reaction = count_render_block
tracking = true
active_dependency = null
// Execute: _$_.set_text(__text0.firstChild, 'Count: ' + _$_.get(count))
result = count_render_block.fn()
// Inside: _$_.get(count)
// Returns 1 (new value)
// Registers dependency again (updates clock in dependency node)
// Store dependency chain
count_render_block.d = active_dependency
END FUNCTION
Dependency updated:
count_render_block.d = {
c: 1, // Updated to current clock value
t: count,
n: null
}
DOM updated: Text node now shows "Count: 1"
Step 3.5: Derived Value Update - The Cascade
Now here's the beautiful part: double depends on count, so when count changes, double needs to recompute. But it only recomputes when someone actually reads it!
Derived Value Recomputation Flow:

The Flow:
countchanged →count.c: 0 → 1count_render_blockupdated (we saw this)branch_blockexecutes (it always executes)- Inside branch:
_$_.get(double)is called - This triggers derived value computation!
For branch_block: is_block_dirty(branch_block)
FUNCTION is_block_dirty(branch_block):
flags = branch_block.f
// Branch blocks always execute (they're control flow)
IF (flags & BRANCH_BLOCK) !== 0: // true
RETURN false // Always execute (not "dirty" but always runs)
END FUNCTION
Why branch blocks always execute:
Branch blocks (if, for) are control flow - they need to check conditions every time. They don't track dependencies themselves, but their children do.
Inside branch: _$_.get(double) - First Access!
This is where derived value computation happens:
FUNCTION get(double):
// double is derived
IF (double.f & DERIVED) !== 0: // true
RETURN get_derived(double) // ← Go here!
END FUNCTION
Algorithm: get_derived(double)
FUNCTION get_derived(double):
// Step 1: Update derived value (compute if needed)
update_derived(double)
// Step 2: Register dependency (branch_block depends on double)
IF tracking: // true
register_dependency(double)
// Step 3: Apply custom getter (none)
IF double.a.get IS NOT undefined: // false
double.__v = trigger_track_get(double.a.get, double.__v)
RETURN double.__v // 2
END FUNCTION
Algorithm: update_derived(double) - The Computation Check
FUNCTION update_derived(double):
value = double.__v // 0 (cached from before)
// Check if needs recomputation
IF value === UNINITIALIZED OR is_tracking_dirty(double.d): // Check!
// Dependencies changed, recompute!
value = run_derived(double)
// Update if value changed
IF value !== double.__v: // 2 !== 0 = true
double.__v = 2 // ← Update value
double.c = increment_clock() // ← Increment clock (2)
END FUNCTION
Algorithm: is_tracking_dirty(double.d) - Check if Count Changed
FUNCTION is_tracking_dirty(double.d):
dependency_chain = double.d // { c: 0, t: count, n: null }
current = dependency_chain
WHILE current IS NOT NULL:
tracked = current.t // count
// THE KEY COMPARISON!
// count.c (1) > dependency.c (0)?
IF tracked.c > current.c: // 1 > 0 = TRUE!
RETURN true // ← DIRTY! Dependencies changed!
current = current.n // null
RETURN false
END FUNCTION
Comparison:
| Value | Before | After |
|---|---|---|
count.c | 0 | 1 ← Changed! |
double.d.c (stored) | 0 | 0 |
| Comparison | - | 1 > 0 = true |
Result: Dependencies are dirty! Recompute double.
Algorithm: run_derived(double) - The Computation
FUNCTION run_derived(double):
// Save context
previous_block = active_block // branch_block
previous_reaction = active_reaction // branch_block
previous_tracking = tracking // true
previous_dependency = active_dependency // null
previous_is_mutating_allowed = is_mutating_allowed // true
TRY:
// Set context for computation
active_block = null // ← No block (computation context)
active_reaction = double // ← double is the reaction!
tracking = true // ← Enable dependency tracking
active_dependency = null // ← Will track count
is_mutating_allowed = false // ← Prevent mutations during computation
// Destroy old child blocks (none)
destroy_computed_children(double)
// Run computation: () => _$_.get(count) * 2
value = double.fn()
// Inside fn():
// _$_.get(count) → returns 1
// Registers count as dependency of double!
// 1 * 2 = 2
// No custom getter
IF double.a.get IS NOT undefined: // false
value = trigger_track_get(double.a.get, value)
RETURN value // 2
FINALLY:
// Restore context
active_block = previous_block
active_reaction = previous_reaction
tracking = previous_tracking
active_dependency = previous_dependency
is_mutating_allowed = previous_is_mutating_allowed
END FUNCTION
Context During Computation:
active_block = null // ← No block
active_reaction = double // ← double is the reaction!
tracking = true // ← Tracking enabled
active_dependency = null // ← Will be built
is_mutating_allowed = false // ← Mutations disabled
Inside double.fn(): _$_.get(count)
FUNCTION get_tracked(count):
value = count.__v // 1
// Register dependency (tracking === true, active_reaction === double)
IF tracking: // true
register_dependency(count) // Registers to double, not branch_block!
RETURN value // 1
END FUNCTION
Dependency Chain Updated:
Before:
double.d = {
c: 0, // ← Old clock value
t: count,
n: null
}
After:
double.d = {
c: 1, // Updated to count's current clock!
t: count,
n: null
}
Derived Value Updated:
Before:
double = {
__v: 0, // ← Old computed value
c: 1, // ← Old clock
d: { c: 0, t: count, n: null }
}
After:
double = {
__v: 2, // New computed value!
c: 2, // Clock incremented!
d: {
c: 1, // Updated dependency clock
t: count,
n: null
}
}
DOM Updated: Text node now shows "Double: 2"!
What Just Happened:
doubleaccessed →get_derived(double)called- Dependencies checked →
count.c (1) > double.d.c (0)= dirty! - Computation ran →
double.fn()executed, readcount(value1) - Dependency registered →
double.dupdated to trackcount - Value updated →
double.__v: 0 → 2,double.c: 1 → 2 - DOM updated → Text shows new value
Key Insight: Derived values are lazy and cached:
- They only compute when accessed
- They only recompute when dependencies are dirty
- They cache the result for subsequent accesses
Phase 4: Toggling ShowDouble
Now let's see what happens when the user clicks the "Toggle" button. This will change showDouble from true to false, demonstrating how conditional blocks respond to changes in their dependencies.
User clicks "Toggle" button: @showDouble = !@showDouble
Step 4.1: Value Update
_$_.set(showDouble, !_$_.get(showDouble))
Execution:
_$_.get(showDouble)returnstrue_$_.set(showDouble, false)executes
Algorithm: set(showDouble, false)
FUNCTION set(showDouble, false):
old_value = showDouble.__v // true
// Value changed
IF false === true: // false
RETURN
// Update value and clock
showDouble.__v = false
showDouble.c = increment_clock() // showDouble.c = 1
// Schedule update
schedule_update(showDouble.b) // root_block
END FUNCTION
Result:
showDouble = {
__v: false,
c: 1,
// ...
}
Step 4.2: Conditional Block Update
The if_block depends on showDouble, so it needs to re-execute.
For if_block: is_block_dirty(if_block)
FUNCTION is_block_dirty(if_block):
// Check dependencies
RETURN is_tracking_dirty(if_block.d)
END FUNCTION
FUNCTION is_tracking_dirty(if_block.d):
dependency_chain = if_block.d // { c: 0, t: showDouble, n: null }
current = dependency_chain
WHILE current IS NOT NULL:
tracked = current.t // showDouble
// Check clock: showDouble.c (1) > dependency.c (0)?
IF tracked.c > current.c: // 1 > 0 = true
RETURN true // DIRTY!
current = current.n
RETURN false
END FUNCTION
Result: if_block is dirty!
Execution: run_block(if_block)
FUNCTION run_block(if_block):
// Set context
active_block = if_block
active_reaction = if_block
tracking = true
active_dependency = null
// Execute: if (_$_.get(showDouble)) { ... }
result = if_block.fn()
// Inside: _$_.get(showDouble) returns false
// Registers dependency (updates clock)
// Condition is false, so set_branch is NOT called
// has_branch remains false
// After execution:
IF NOT has_branch: // true
update_branch(null, null) // Destroy branch!
END FUNCTION
Algorithm: update_branch(null, null)
FUNCTION update_branch(null, null):
condition = null
// Destroy old branch
IF b !== null: // true (branch_block exists)
destroy_block(branch_block)
b = null
END FUNCTION
Algorithm: destroy_block(branch_block)
FUNCTION destroy_block(branch_block):
// Destroy children recursively
destroy_block_children(branch_block)
// Run teardown
run_teardown(branch_block)
// Remove from DOM
remove_block_dom(branch_block.s.start, branch_block.s.end)
// Unlink from parent
// (parent.first/last/next pointers updated)
END FUNCTION
Result: The <p>Double: 2</p> element is removed from the DOM!
Dependency updated:
if_block.d = {
c: 1, // Updated to showDouble's current clock
t: showDouble,
n: null
}
Phase 5: Toggling Back
To complete our understanding, let's see what happens when the user clicks "Toggle" again. This demonstrates how the system handles conditional blocks that are recreated, and how derived values use caching to avoid unnecessary recomputation.
User clicks "Toggle" again: @showDouble = !@showDouble
Step 5.1: Value Update
showDouble.__v = true
showDouble.c = 2
Step 5.2: Conditional Block Re-execution
if_block is dirty (showDouble.c (2) > dependency.c (1))
Execution:
_$_.get(showDouble)returnstrue- Condition is
true, soset_branch()is called - New branch block is created
- Double value is rendered again
Inside branch: _$_.get(double)
FUNCTION get_derived(double):
update_derived(double)
// Check dependencies
is_tracking_dirty(double.d)
// count.c (1) > double.d.c (1)?
// 1 > 1 = false // NOT dirty!
// So use cached value
RETURN double.__v // 2 (cached!)
END FUNCTION
Key Point: The derived value is cached and doesn't recompute because count hasn't changed!
The Caching Check:
| Value | Current | Stored | Comparison |
|---|---|---|---|
count.c | 1 | 1 | 1 > 1 = false |
| Result | - | - | Not dirty → Use cache! |
This is the caching optimization in action - double was already computed when count was 1, so there's no need to recompute it again!
Performance Analysis
Now that we've traced through the complete lifecycle of our component, let's analyze the performance characteristics of Ripple's reactivity system. Understanding these metrics helps explain why the system is efficient and scales well.
Time Complexity
| Operation | Complexity | Notes |
|---|---|---|
get() (simple) | O(1) | Constant time read |
get() (derived) | O(C + D) | Computation + dependency check |
set() | O(H) | Height of block tree |
is_block_dirty() | O(D) | Dependency chain traversal |
flush_updates() | O(B) | All blocks in tree |
Where:
- C = computation time
- D = dependency count
- H = block tree height
- B = total blocks
Space Complexity
| Structure | Complexity | Notes |
|---|---|---|
| Tracked object | O(1) | Fixed size |
| Dependency chain | O(D) | One node per dependency |
| Block tree | O(B) | One block per reactive unit |
Optimizations Observed
- Dependency Deduplication: Same tracked value accessed multiple times creates only one dependency
- Clock-Based Change Detection: O(1) comparison vs O(N) equality check
- Lazy Derived Computation: Computed only when accessed and dirty
- Cached Derived Values: Don't recompute if dependencies unchanged
- Early Exit:
set()exits if value unchanged - Microtask Batching: Single DOM update cycle per event loop turn
Key Insights: The Big Picture
1. Fine-Grained Reactivity
Only the specific blocks that depend on changed values re-execute. In our example:
When count changes:
count_render_blockexecutes (depends oncount)doublecomputation runs (depends oncount)if_blockdoesn't execute (doesn't depend oncount)- Root block doesn't re-execute (not dirty)
When showDouble changes:
if_blockexecutes (depends onshowDouble)count_render_blockdoesn't execute (doesn't depend onshowDouble)doubledoesn't recompute (not accessed)
The Magic: Each block only re-executes when its specific dependencies change!
2. Dependency Chain Structure
Dependencies form a linked list on each block:

block.d → Dependency1 → Dependency2 → Dependency3 → null
↓ ↓ ↓
{c, t, n} {c, t, n} {c, t, n}
Each dependency node stores:
c: Clock value at registration time (for comparison)t: Reference to tracked value (which value it depends on)n: Next dependency (forms linked list)
Example from our component:
count_render_block.d → { c: 0, t: count, n: null }
This says: "This block depends on count, registered when count's clock was 0."
3. Clock-Based Change Detection
Instead of comparing values (expensive!), Ripple compares clock values:

// Expensive (O(N) for objects):
if (tracked.__v !== dependency.stored_value) {
// Value changed!
}
// Efficient (O(1)):
if (tracked.c > dependency.c) {
// Value changed! (clock incremented)
}
Why this works:
- Every
set()increments the clock - Dependencies store the clock value when registered
- Comparison is just integer comparison (O(1))
Example:
// Initial state
count.c = 0
dependency.c = 0 // Registered when clock was 0
// After change
count.c = 1 // ← Incremented by set()
dependency.c = 0 // ← Still 0
// Check: 1 > 0 = true → DIRTY!
4. Derived Value Caching
Derived values cache their result and only recompute when:
Conditions for recomputation:
- First access:
__v === UNINITIALIZED - Dependencies dirty:
is_tracking_dirty(double.d) === true
Otherwise: Return cached value!
Example from our component:
// First access: count = 0
double.__v = 0 // Computed
// count changes to 1
count.c = 1
// Access double again
is_tracking_dirty(double.d) // 1 > 0 = true
double.__v = 2 // Recomputed
// Access double again (count still 1)
is_tracking_dirty(double.d) // 1 > 1 = false
double.__v = 2 // Cached! No recomputation!
This prevents unnecessary computation - if count hasn't changed, double doesn't recompute!
5. Block Tree Execution
Blocks form a tree structure:

RootBlock (always executes)
├─→ RenderBlock (count) - executes if count changed
├─→ IfBlock (showDouble) - executes if showDouble changed
│ └─→ BranchBlock (double) - executes if condition true
│ └─→ RenderBlock (double text) - executes if double changed
└─→ RenderBlock (buttons) - always executes
Update Propagation:
- Updates start at root block
- Traverse down the tree (depth-first)
- Only dirty blocks execute
- Clean blocks skip execution
Visual Flow:

This is fine-grained reactivity - only what needs to update, updates!
Now that we've explored the key insights, let's summarize everything we've learned by tracing our component through its entire lifecycle: