rs-x and Angular Signals are two different reactive systems. This page measures them side-by-side across three scenarios so you can understand where each system excels and why.
How each system works
rs-x
rs-x binds a string expression to a plain JavaScript object. The expression is parsed once and cached; subsequent bindings clone the cached tree. rs-x attaches a JavaScript Proxy to the model so it can detect property mutations automatically — you change model.price = 42 and rs-x sees it. Re-evaluation is scheduled as a microtask, so it is asynchronous.
Native Observable and Promise values in model fields are handled transparently: rs-x subscribes to them and propagates emitted values without any extra configuration.
Angular Signals
Angular Signals are an explicit reactive primitive. You create a signal(initialValue) and read it inside a computed(() => expression). Angular tracks which signals each computed reads, and marks that computed stale when any of those signals change. Re-evaluation happens synchronously the next time the computed is read.
For Observable values, Angular provides toSignal(observable$, { injector }) which subscribes internally and updates the signal synchronously on each emission.
What is different
The key structural difference is string expressions vs compiled TypeScript. rs-x lets you write "price * quantity" and bind it to any plain object; Angular Signals require you to express the same computation as a JavaScript arrow function inside computed(). rs-x pays a parsing cost once at bind time and then evaluates an AST on each change; Angular Signals run native compiled JavaScript.
rs-x is completely independent of Angular — it has no framework dependency and works with any plain JavaScript object.
Benchmarks were run with node --expose-gc --max-old-space-size=4096 on Apple M4, Node.js v25.4.0. Times are medians of multiple runs with forced GC between sizes.
Snapshot date: 2026-03-29. rs-x source reports: reports/angular-signals-comparison/benchmark-2026-03-29-compiled.json and reports/angular-signals-comparison/benchmark-2026-03-29-tree.json.
Scenario 1 — Sync identifier
N bindings, each watching a unique field (field0, field1, …) on its own separate model.
rs-x:rsx('fieldN')(model) — Proxy intercepts writes to model.fieldN, schedules a microtask, re-evaluates.
Angular:signal(v) + computed(() => s()) — plain JS objects, no Proxy, no parsing. Update with s.set(v), read computed to force re-evaluation.
Bind time
Angular creates bare JS objects — no parsing, no Proxy, no watcher registration. rs-x has a fixed per-binding cost: clone cached tree, wrap model in Proxy, register watchers. The gap is constant per binding (~30 µs/binding for rs-x vs ~0.5 µs for Angular).
rs-x compiledrs-x treeAngular Signals
Bindings
rs-x compiled (ms)
rs-x tree (ms)
Angular (ms)
1,000
34.30
33.86
0.92
3,000
146.94
118.56
2.42
5,000
229.56
226.45
3.91
10,000
475.55
466.62
6.62
Single update time
One field changes on one model; only that expression re-evaluates. Both systems are effectively O(1) — cost does not grow with total binding count. rs-x is ~0.07–0.10 ms because it schedules a microtask before re-evaluating. Angular is ~0.009–0.012 ms because it re-evaluates synchronously on the next read.
rs-x compiledrs-x treeAngular Signals
Bindings
rs-x compiled (ms)
rs-x tree (ms)
Angular (ms)
1,000
0.1108
0.1137
0.0136
3,000
0.0874
0.0805
0.0100
5,000
0.0810
0.0897
0.0115
10,000
0.1066
0.0785
0.0142
Bulk update time
All N fields change; all N expressions re-evaluate. Both are O(N). Angular is faster because it skips the microtask scheduler and evaluates native compiled JavaScript directly.
rs-x compiledrs-x treeAngular Signals
Bindings
rs-x compiled (ms)
rs-x tree (ms)
Angular (ms)
1,000
6.34
6.70
0.38
3,000
17.67
17.60
1.30
5,000
29.74
29.55
2.04
10,000
56.22
53.15
4.98
Scenario 2 — Async identifier (Observable)
Each model field holds a BehaviorSubject. The binding tracks emitted values.
rs-x:rsx('stream')(model) where model.stream is a BehaviorSubject. rs-x detects the Observable via duck-typing and subscribes automatically. The initial value is delivered asynchronously.
Angular:toSignal(behaviorSubject, { injector }) — Angular subscribes internally and calls signal.set() synchronously on each emission.
Bind time
Bind time includes the first emission arriving. Angular's toSignal is fast because the BehaviorSubject emits synchronously on subscribe. rs-x needs a microtask round-trip to deliver the first value. Both scale linearly with binding count.
rs-x compiledrs-x treeAngular Signals
Bindings
rs-x compiled (ms)
rs-x tree (ms)
Angular (ms)
1,000
41.82
41.74
3.51
3,000
178.79
179.27
5.75
5,000
291.31
294.71
8.81
10,000
595.80
613.09
27.07
Single update time
One subject emits; one expression updates. O(1) for both — cost does not grow with binding count. rs-x ~0.05 ms (microtask overhead); Angular ~0.011–0.017 ms (synchronous read).
rs-x compiledrs-x treeAngular Signals
Bindings
rs-x compiled (ms)
rs-x tree (ms)
Angular (ms)
1,000
0.0653
0.0604
0.0203
3,000
0.0715
0.0827
0.0152
5,000
0.0665
0.0679
0.0124
10,000
0.0787
0.0791
0.0153
Bulk update time
All N subjects emit; all N expressions update. O(N) for both. Angular stays faster due to synchronous propagation; rs-x adds a per-emission microtask cost.
rs-x compiledrs-x treeAngular Signals
Bindings
rs-x compiled (ms)
rs-x tree (ms)
Angular (ms)
1,000
8.93
8.58
1.43
3,000
22.78
23.16
4.16
5,000
37.63
36.35
6.78
10,000
69.22
75.67
13.24
Scenario 3 — Same-model: all 1,000 generated expressions
All 1,000 expressions from generated-benchmark-expression-strings.ts are bound to a single shared model { x, y }. Each expression is unique and deeply nested — the simplest has ~60 AST nodes; the most complex has over 120.
rs-x:rsx(generatedExpr[i])({ x, y }) — each string is parsed into an AST (1,000 unique parses, each tree has 60–120+ nodes). Re-evaluation walks the tree interpreting each node. When x changes, all 1,000 expressions must re-evaluate because they all depend on x.
Angular:computed(() => fn(xSig(), ySig())) where fn = new Function('x', 'y', expr) — each expression string is compiled once to a native JavaScript function (V8 JIT-compiled). Re-evaluation calls the native function directly at full CPU speed.
This scenario reveals the fundamental difference in evaluation strategy. The single-update measurement triggers all 1,000 dependents (fan-out). The bulk-update measurement does 10 sequential x-changes, each triggering all 1,000.
System
Bind — create+init (ms)
Dispose 1,000 (ms)
Single x-change (ms)
Bulk — 10× x-change (ms)
rs-x compiled
15.344
4.008
5.917
31.686
rs-x tree
590.416
28.035
63.286
486.179
Angular Signals
0.750
GC
1.467
13.446
Bind cost (create+init): compiled mode avoids AST walking at update time and reduces this shape to 15.344 ms vs tree mode at 590.416 ms. Angular still leads because it runs native computed functions directly (0.750 ms).
Dispose cost: compiled mode is 4.008 ms and tree mode is 28.035 ms for 1,000 shared-model expressions. Angular signals are garbage-collected — no explicit teardown cost.
Why update is still slower than Angular: even in compiled mode, rs-x still pays ownership/watch bookkeeping and scheduling overhead around expression invalidation. Angular calls native functions from new Function() 1,000 times and reads each computed(). Real-world expressions (price * quantity, isActive && !isHidden) have far fewer nodes and are much closer to the identifier-only benchmarks in scenarios 1 and 2.
Summary
Angular Signals are faster in these benchmarks. The practical question is not raw speed in isolation, but whether users can perceive the difference in real screens and interactions.
With compiled expressions enabled, rs-x remains very fast for typical app workloads, while giving you capabilities that are hard to match with purely code-defined reactivity.
rs-x is designed to make reactivity transparent. You define a model as a plain JavaScript object — no signals, no decorators, no reactive wrappers. You write an expression string. rs-x handles the rest: it detects which fields the expression reads, watches them for changes, recomputes automatically, propagates the result, and cleans up when the binding is released. Change detection is not something you configure — it is solved for you.
Observable and Promise fields are handled the same way. A field that holds a BehaviorSubject or a Promise is not a special case — rs-x subscribes transparently and the expression evaluates to the resolved value. No extra API, no toSignal(), no unwrapping in the expression.
With Angular Signals, you are responsible for every part of the reactive graph: declaring each field as a signal, deriving each computed value explicitly, handling async separately with toSignal(), and cleaning up with DestroyRef or takeUntilDestroyed. For deeply-nested arithmetic evaluated at high frequency, that control is worth it — native compiled functions are dramatically faster than AST evaluation. For typical SPA bindings (identifiers, member access, simple arithmetic), compiled rs-x is generally within response-time limits users can feel, and rs-x can compensate with flexibility: runtime expression strings, automatic dependency wiring, transparent async handling, and framework-agnostic usage.
Speed matters most when users notice latency. If a workload is truly compute-bound and evaluated at very high frequency, Angular Signals is the stronger raw-performance choice. If your priority is expressive runtime behavior with minimal reactive boilerplate, compiled rs-x gives strong performance with a broader feature set.
String expressions do not mean losing type safety. rs-x ships a VS Code extension and a build-time compiler plugin that read your model types and provide full IntelliSense, autocomplete, and compile-time errors inside expression strings. Invalid expressions are caught before they ship.