Quick Recap
In the previous article, we looked at the role of the Scheduler.
The Scheduler is responsible for arranging downstream jobs after data changes and batching those updates so the system does not recompute everything immediately and repeatedly.
However, scheduling alone is not enough to make a reactive system stable.
If a signal system is expected to run for a long time, especially inside real applications where components mount, unmount, reconnect, and re-track dependencies, two topics become unavoidable:
- memory management
- dependency graph management
This article focuses on these two layers.
Why Do We Need a Graph?
In a fine-grained reactivity system, the relationships between signal, computed, and effect are essentially a directed graph.
Nodes
A node represents a reactive unit in the system.
- Signal: the source of state
- Computed: a derived value calculated from other nodes
- Effect: a side-effect node, such as DOM updates, logging, or external synchronization
Edges
An edge represents a dependency relationship.
When an effect reads a signal, or when a computed reads another computed, the system creates an edge between the dependent node and the dependency it read.
This gives the runtime a structured way to answer one important question:
When this value changes, who needs to know?
This design serves two major purposes.
1. Precise Updates
When a signal changes, the runtime does not need to notify the entire world.
It only follows the graph edges and finds the nodes that are actually affected.
2. Avoiding Redundant Computation
The graph allows the system to track which computations are still valid and which ones have become stale.
Instead of recomputing everything eagerly, the runtime can mark affected nodes as dirty and only recompute them when necessary.
Without a graph, the Scheduler would be forced to use a broad broadcast mechanism. Every update would become much more expensive because the system would lose the ability to know what is actually related to what.
The Memory Management Problem
Once we introduce a graph, we also introduce ownership and lifetime problems.
A graph is not just a set of values. It is a set of references.
If those references are not cleaned up correctly, the system may continue holding onto nodes that should already be gone.
This is where memory management becomes part of the core design.
1. Dangling Nodes
A dangling node appears when an effect or computed is no longer needed, but its node is still retained by the graph.
This commonly happens when:
- a React component unmounts
- a Vue component is destroyed
- a manually created effect is no longer used
- a derived computation becomes unreachable
If the corresponding graph node is not cleaned up, it may continue to be referenced by upstream nodes.
That means the runtime may still keep it alive, even though the application no longer needs it.
This is a typical source of memory leaks in reactive systems.
The solution is straightforward in principle:
When a node is disposed, unlink all of its upstream dependencies.
Disposal is not only about stopping future execution. It is also about removing the node from the graph so it can be released.
2. Stale Edges
A stale edge appears when a node's dependency relationship changes, but the old edge remains in the graph.
For example:
const value = computed(() => {
if (enabled.get()) {
return sourceA.get();
}
return sourceB.get();
});
When enabled is true, this computed value depends on sourceA.
When enabled becomes false, it should stop depending on sourceA and start depending on sourceB instead.
If the old edge to sourceA is not removed, then future updates to sourceA may still mark this computed value as stale, even though it no longer reads from sourceA.
That is not only inefficient. It also means the graph no longer reflects the real dependency structure of the program.
The solution is to rebuild dependency edges during the tracking phase.
In practice, this usually means:
- collect dependencies while executing the tracked function
- compare the new dependencies with the old ones
- remove edges that are no longer used
- add edges that are newly discovered
This is why a signal system needs explicit link and unlink operations.
3. GC and Retain Cycles
JavaScript's garbage collector can handle many circular references.
However, that does not mean a reactive runtime can ignore reference ownership.
If the internal graph structure keeps strong references to nodes that are no longer reachable from user code, those nodes may still remain alive.
The problem is not simply that a cycle exists.
The real problem is that the runtime may accidentally become the owner of objects that should have been released.
There are several ways to reduce this risk:
- avoid unnecessary bidirectional ownership
- make disposal explicit where lifecycle boundaries are clear
- use
WeakMapfor metadata lookup when the runtime should not own the object - consider
WeakRefonly when the use case truly requires weak object references
In my own design preference, I would start with explicit disposal first.
It makes lifecycle boundaries easier to reason about. Weak references can be useful, but they should not become a replacement for a clear ownership model.
A Minimal Graph Structure
In a signal system, there is usually a dedicated Graph Layer responsible for managing nodes, dependencies, subscribers, and disposal.
A simplified node structure may look like this:
export interface Node {
deps?: Set<Node>; // upstream dependencies
subs?: Set<Node>; // downstream subscribers
stale?: boolean; // whether the node is dirty
disposed?: boolean; // whether the node has been released
}
Different libraries may use different names or organize the structure differently, but the underlying idea is usually similar.
If you read through the source code of several reactive libraries, you will often find the same core concepts under different abstractions.
Linking and Unlinking
When a reactive node reads another node during tracking, the runtime creates an edge.
A minimal implementation may look like this:
export function link(source: Node, target: Node) {
(source.deps ??= new Set()).add(target);
(target.subs ??= new Set()).add(source);
}
export function unlink(source: Node, target: Node) {
source.deps?.delete(target);
target.subs?.delete(source);
}
Here, source is the dependent node, and target is the node it depends on.
So the relationship is stored in two directions:
-
source.depspoints to upstream dependencies -
target.subspoints to downstream subscribers
This bidirectional structure makes propagation efficient, but it also means cleanup must be handled carefully.
Disposing a Node
When an effect is destroyed, the runtime must remove it from its upstream dependencies.
A simplified disposal function may look like this:
export function dispose(node: Node) {
if (node.deps) {
for (const dep of node.deps) {
dep.subs?.delete(node);
}
}
node.deps?.clear();
node.subs?.clear();
node.disposed = true;
}
This ensures that:
- stale dependencies do not remain in the graph
- upstream nodes no longer retain the disposed node
- memory can be released after a component or effect is destroyed
In other words, dispose() is not just an API for stopping an effect.
It is a graph cleanup operation.
How the Scheduler Uses the Graph
The Scheduler does not work in isolation.
It relies on the graph to know which nodes should be updated after a change.
A typical flow looks like this:
-
signal.set()updates the value and marks the signal node as stale. - The Scheduler queues affected jobs.
-
flushJobs()drains the queue. - The runtime follows
substo propagate invalidation downstream. - If some nodes no longer have subscribers, the runtime may auto-unlink or dispose them.
The important point is this:
The Scheduler decides when work should run, but the graph decides where the work should propagate.
Without the graph, scheduling becomes blind.
Without the Scheduler, graph updates can become chaotic and repetitive.
A stable signal runtime needs both.
Optimization Strategies
In small examples, basic graph management may be enough.
But in real applications, the graph may grow, shrink, and change shape constantly.
That is why signal systems usually need additional optimization strategies.
1. Auto-Unlink
In the standard link / unlink flow, a node may eventually lose all of its downstream subscribers.
If that node still keeps its upstream dependencies, invalid edges may remain in the graph for a long time.
This may not immediately cause incorrect behavior, but it can make the graph larger than necessary.
The solution is auto-unlink:
function autoUnlink(node: Node) {
if (!node.subs || node.subs.size === 0) {
for (const dep of node.deps ?? []) {
dep.subs?.delete(node);
}
node.deps?.clear();
}
}
With this approach, when a computed or effect no longer has subscribers, it can clean up its upstream dependencies automatically.
This prevents dangling dependency relationships from staying in the graph.
In the context of React or Vue, this is similar to removing event listeners during unmount.
The goal is the same: release resources when the lifecycle has ended.
2. Equals Strategy and Performance
In the signal.set() flow, we usually perform an equality check before triggering invalidation.
const set = (next: T) => {
if (!equals(value, next)) {
value = next;
markStale(thisNode);
}
};
This avoids unnecessary updates.
However, equality checking is not free. Different equality strategies have different trade-offs.
Object.is
Object.is is usually the default option.
It is native to JavaScript, fast, and predictable.
It works especially well for primitives and reference equality.
The limitation is that objects and arrays are compared by reference. If a new object is created, it will be treated as a new value even if its contents are structurally similar.
Shallow Equal
Shallow equality can be useful for common object-shaped UI state.
It can reduce unnecessary updates when object references change but the top-level fields remain the same.
The trade-off is that shallow comparison has an O(n) cost based on the number of keys or items being compared.
Custom Equal
Custom equality gives users full control.
It can support domain-specific comparison rules, Immutable.js structures, AST nodes, or deep equality.
However, this flexibility also introduces risk.
A poorly designed custom equality function can become more expensive than the update it tries to avoid.
Practical Guidance
For high-frequency updates, such as game loops, pointer tracking, animation, or real-time telemetry, Object.is is usually the safest default.
For form state or regular UI state, shallow equality can be a pragmatic compromise.
For complex domain data, custom equality may be useful, but it should be introduced deliberately.
The key is not to make equality smarter by default.
The key is to choose the cheapest strategy that preserves correctness for the use case.
3. Lazy Disposal
Some systems delay the actual cleanup work instead of disposing nodes immediately.
This is sometimes called lazy disposal.
The benefit is that it can reduce immediate overhead during hot update paths.
The cost is that the runtime now needs another mechanism to decide when deferred cleanup should happen.
Lazy disposal is not automatically better than explicit disposal.
It is a trade-off between immediate cleanup cost and delayed resource retention.
4. Weak References for Dependencies
Some metadata can be stored through WeakMap so the runtime does not accidentally extend the lifetime of user-owned objects.
This is useful when the runtime needs to associate internal metadata with external objects but should not become their owner.
WeakRef can also be used in more advanced cases, but it should be applied carefully.
Weak references can help reduce retention problems, but they also make lifecycle behavior harder to reason about.
For most signal systems, explicit graph cleanup should still be the foundation.
5. Layered Scheduler
Another optimization is to split work into different layers.
For example:
- computation layer
- UI update layer
- I/O layer
Not all jobs have the same urgency.
A derived value recomputation, a DOM update, a network request, and a logging effect should not necessarily be scheduled with the same priority.
Once the runtime can distinguish these layers, the Scheduler can make more precise decisions.
This becomes especially important when the system needs to support larger applications, async resources, streaming updates, or framework adapters.
Conclusion
The Scheduler is not just a mechanism for running jobs later.
A serious signal system also needs to maintain its dependency graph and manage memory correctly.
The graph tells the runtime where changes should propagate.
Memory management ensures that obsolete nodes and edges do not stay alive forever.
Together, these two layers determine whether the system can remain efficient after running for a long time.
This is why implementations like Solid, Vue, and Preact Signals all contain dedicated logic for dependency tracking, graph cleanup, and lifecycle management.
In the next article, we will continue with priority and layered scheduling: how to optimize different kinds of tasks by giving the Scheduler a more structured execution model.

Top comments (0)