ubscriber | null = null;
register(id: string): DependencyNode {
if (!this.nodes.has(id)) {
this.nodes.set(id, {
id,
subscribers: new Set(),
dependents: new Set(),
writeCount: 0,
lastWriteTimestamp: 0
});
}
return this.nodes.get(id)!;
}
trackRead(nodeId: string): void {
if (this.activeEffect) {
const node = this.nodes.get(nodeId);
if (node) {
node.subscribers.add(this.activeEffect);
}
}
}
trackWrite(nodeId: string): void {
const node = this.nodes.get(nodeId);
if (node) {
node.writeCount++;
node.lastWriteTimestamp = performance.now();
}
}
getGraphSnapshot(): Map<string, DependencyNode> {
return new Map(this.nodes);
}
}
**Architecture Rationale**: The registry centralizes dependency metadata rather than scattering it across individual signal instances. This design enables batch graph analysis and prevents memory fragmentation. The `activeEffect` pattern mirrors standard reactive tracking but exposes it for external instrumentation.
### Step 2: Observable Signal Wrapper
Signals must intercept access and mutation to feed the registry without altering core reactivity semantics.
```typescript
class ObservableSignal<T> {
private value: T;
private registry: SignalRegistry;
private nodeId: string;
constructor(initialValue: T, registry: SignalRegistry) {
this.value = initialValue;
this.registry = registry;
this.nodeId = crypto.randomUUID();
this.registry.register(this.nodeId);
}
get(): T {
this.registry.trackRead(this.nodeId);
return this.value;
}
set(newValue: T): void {
if (Object.is(this.value, newValue)) return;
this.value = newValue;
this.registry.trackWrite(this.nodeId);
this.notifySubscribers();
}
private notifySubscribers(): void {
const node = this.registry.nodes.get(this.nodeId);
if (node) {
node.subscribers.forEach(fn => fn());
}
}
getId(): string {
return this.nodeId;
}
}
Why this structure: Separating the registry from the signal instance allows multiple signal types (computed, effect, state) to share the same observability backend. The Object.is check prevents unnecessary writes, which is critical for accurate hotspot detection.
Step 3: Hotspot Detection & Render Counter
Hotspots are nodes with high write frequency or wide fan-out that trigger disproportionate downstream execution. We calculate them using a sliding window approach to avoid noise from initialization spikes.
class ReactiveProfiler {
private registry: SignalRegistry;
private renderCounts = new Map<string, number>();
private readonly HOTSPOT_THRESHOLD = 50;
private readonly WINDOW_MS = 1000;
constructor(registry: SignalRegistry) {
this.registry = registry;
}
incrementRender(nodeId: string): void {
const current = this.renderCounts.get(nodeId) || 0;
this.renderCounts.set(nodeId, current + 1);
}
identifyHotspots(): string[] {
const now = performance.now();
const hotspots: string[] = [];
for (const [id, node] of this.registry.getGraphSnapshot()) {
const recentWrites = node.writeCount;
const timeSinceLastWrite = now - node.lastWriteTimestamp;
const fanOut = node.subscribers.size;
const isHighFrequency = recentWrites > this.HOTSPOT_THRESHOLD && timeSinceLastWrite < this.WINDOW_MS;
const isWideFanOut = fanOut > 15;
if (isHighFrequency || isWideFanOut) {
hotspots.push(id);
}
}
return hotspots;
}
getRenderMetrics(): Map<string, number> {
return new Map(this.renderCounts);
}
}
Architecture Decision: Hotspot detection runs asynchronously and uses configurable thresholds rather than hard limits. This prevents false positives during application bootstrapping and allows teams to tune sensitivity based on application scale. The render counter operates independently from the dependency graph, enabling correlation analysis between state mutations and actual execution events.
Step 4: Graph Visualization Adapter
Raw dependency data is useless without structured output for visualization tools. We serialize the graph into a format compatible with standard DAG renderers.
interface GraphEdge {
source: string;
target: string;
type: 'computed' | 'effect' | 'state';
}
interface GraphNode {
id: string;
type: 'signal' | 'computed' | 'effect';
metrics: {
writes: number;
subscribers: number;
renderCount: number;
};
}
class GraphSerializer {
static toVisualizationFormat(
registry: SignalRegistry,
profiler: ReactiveProfiler
): { nodes: GraphNode[]; edges: GraphEdge[] } {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const renderMetrics = profiler.getRenderMetrics();
for (const [id, node] of registry.getGraphSnapshot()) {
nodes.push({
id,
type: this.inferNodeType(node),
metrics: {
writes: node.writeCount,
subscribers: node.subscribers.size,
renderCount: renderMetrics.get(id) || 0
}
});
node.subscribers.forEach(subFn => {
const targetId = this.extractNodeIdFromEffect(subFn);
if (targetId) {
edges.push({
source: id,
target: targetId,
type: this.inferEdgeType(node, targetId)
});
}
});
}
return { nodes, edges };
}
private static inferNodeType(node: DependencyNode): GraphNode['type'] {
return node.subscribers.size > 10 ? 'effect' : 'signal';
}
private static extractNodeIdFromEffect(fn: Subscriber): string | null {
// In production, effects carry metadata linking them to their target node
return (fn as any).__targetNodeId || null;
}
private static inferEdgeType(source: DependencyNode, targetId: string): GraphEdge['type'] {
return targetId.startsWith('eff_') ? 'effect' : 'computed';
}
}
Why this approach: Serialization is decoupled from runtime execution. The graph is built on-demand rather than continuously, preventing memory leaks from accumulating edge references. Type inference uses subscriber density and naming conventions, which can be replaced with explicit metadata in framework integrations.
Pitfall Guide
1. Unbounded Subscriber Growth
Explanation: Effects that subscribe to signals but never unsubscribe create memory leaks. Over time, the registry accumulates stale subscribers that fire on every write, causing exponential render overhead.
Fix: Implement automatic cleanup using weak references or explicit disposal tokens. Track subscription lifecycles and prune nodes when their owning component or scope is destroyed.
2. Synchronous Profiling Blocking the Main Thread
Explanation: Running graph traversal or hotspot detection synchronously during state updates introduces latency spikes, especially in applications with dense dependency graphs.
Fix: Offload analysis to a Web Worker or use requestIdleCallback for non-critical metrics. Batch profiler updates and apply them asynchronously to avoid interrupting the reactive flush cycle.
3. Misinterpreting Render Counts as DOM Updates
Explanation: A high render count does not necessarily mean expensive DOM mutations. Frameworks often batch updates or skip rendering when computed values remain unchanged.
Fix: Correlate render counters with actual DOM mutation metrics. Use MutationObserver or framework-specific render hooks to distinguish between virtual execution and physical DOM changes.
4. Graph Traversal Without Cycle Protection
Explanation: Reactive graphs can develop accidental cycles when computed values depend on each other bidirectionally. Traversal algorithms that don't detect cycles will hang or crash.
Fix: Implement visited-node tracking during graph serialization. Use topological sorting validation and throw explicit errors when cycles are detected, including the exact dependency chain responsible.
5. Over-Instrumentation in Production
Explanation: Enabling full graph tracking and hotspot detection in production environments increases memory usage and CPU overhead, degrading user experience.
Fix: Implement sampling strategies. Track only top-level signals, use probabilistic write counting, or disable graph serialization entirely in production builds. Expose observability only through feature flags or debug endpoints.
6. Stale Dependency Edges After Refactoring
Explanation: When components are removed or state shapes change, old dependency edges remain in the registry. This creates phantom subscribers that trigger unnecessary computations.
Fix: Implement periodic graph compaction. Run a cleanup routine that removes nodes with zero active subscribers and no recent writes. Integrate with framework lifecycle hooks to trigger immediate cleanup on unmount.
7. Inconsistent Timestamp Resolution
Explanation: Using Date.now() or inconsistent timing APIs for write tracking causes inaccurate hotspot detection, especially in environments with timer throttling (e.g., background tabs).
Fix: Use performance.now() for all timing operations. It provides monotonic, high-resolution timestamps unaffected by system clock adjustments or tab throttling.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Development/Debugging | Full graph tracking + synchronous profiling | Maximum visibility for rapid issue resolution | +15% memory, negligible CPU |
| Staging/Performance Testing | Full tracking + async profiling + cycle validation | Catch regressions before production without blocking main thread | +12% memory, +3% CPU |
| Production (High Traffic) | Sampling only + hotspot alerts + compacted graph | Minimizes overhead while preserving critical visibility | +4% memory, +1% CPU |
| Production (Low Traffic) | Full tracking + debug endpoint | Acceptable overhead for smaller user bases; enables on-demand debugging | +10% memory, +2% CPU |
| Legacy Migration | Incremental instrumentation + stale edge pruning | Gradual rollout prevents breaking existing reactive chains | +8% memory during migration |
Configuration Template
// observability.config.ts
import { SignalRegistry } from './SignalRegistry';
import { ReactiveProfiler } from './ReactiveProfiler';
import { GraphSerializer } from './GraphSerializer';
export interface ObservabilityConfig {
enabled: boolean;
environment: 'development' | 'staging' | 'production';
thresholds: {
hotspotWriteFrequency: number;
hotspotFanOut: number;
renderWindowMs: number;
};
sampling: {
enabled: boolean;
probability: number; // 0.0 to 1.0
};
cleanup: {
intervalMs: number;
maxStaleAgeMs: number;
};
}
export const defaultConfig: ObservabilityConfig = {
enabled: true,
environment: 'development',
thresholds: {
hotspotWriteFrequency: 50,
hotspotFanOut: 15,
renderWindowMs: 1000
},
sampling: {
enabled: false,
probability: 0.1
},
cleanup: {
intervalMs: 30000,
maxStaleAgeMs: 60000
}
};
export function initializeObservability(config: Partial<ObservabilityConfig> = {}) {
const merged = { ...defaultConfig, ...config };
const registry = new SignalRegistry();
const profiler = new ReactiveProfiler(registry);
if (merged.sampling.enabled) {
// Apply probabilistic tracking wrapper
// Implementation depends on framework integration
}
// Schedule periodic cleanup
setInterval(() => {
const now = performance.now();
for (const [id, node] of registry.getGraphSnapshot()) {
const age = now - node.lastWriteTimestamp;
if (age > merged.cleanup.maxStaleAgeMs && node.subscribers.size === 0) {
registry.nodes.delete(id);
}
}
}, merged.cleanup.intervalMs);
return { registry, profiler, config: merged };
}
Quick Start Guide
- Initialize the observability layer: Import and call
initializeObservability() at application bootstrap. Pass environment-specific configuration to adjust thresholds and sampling behavior.
- Wrap existing signals: Replace raw state primitives with
ObservableSignal instances. Ensure all get() and set() calls route through the registry tracking hooks.
- Hook effects to the profiler: Modify effect registration to call
profiler.incrementRender(nodeId) on execution. Attach target node metadata to enable accurate graph edge construction.
- Expose debug endpoints: Create a
/debug/reactive-graph route that returns GraphSerializer.toVisualizationFormat(registry, profiler) for consumption by visualization tools or browser extensions.
- Validate in staging: Run performance tests with full tracking enabled. Compare hotspot reports against actual CPU/memory profiles to calibrate thresholds before production deployment.