Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
Yes
Description
PR #62284 (commit 3c008c9) introduced an O(N²) performance regression for computed signals that depend on many producers.
The refactor from arrays to linked lists in the reactive graph (producerNode[] → producers/producersTail linked list) introduced a function isValidLink() that performs a linear scan of the entire producer linked list every time a previously-tracked signal is re-read during a recomputation.
In Angular v19 (arrays), re-reading an already-tracked producer was O(1) via indexed array access. In v20+ (linked lists), isValidLink() walks from consumer.producers (head) through the chain to find checkLink, making each re-read O(K) where K is the position in the list. This compounds to O(N²) for N producers.
The problematic code path
In producerAccessed():
const prevConsumerLink = node.consumersTail;
if (
prevConsumerLink !== undefined &&
prevConsumerLink.consumer === activeConsumer &&
(!isRecomputing || isValidLink(prevConsumerLink, activeConsumer)) // ← O(N) scan
) {
return;
}
isValidLink() walks the entire linked list from head to tail:
function isValidLink(checkLink, consumer) {
const producersTail = consumer.producersTail;
if (producersTail !== undefined) {
let link = consumer.producers; // start at HEAD
do {
if (link === checkLink) return true;
if (link === producersTail) break;
link = link.nextProducer; // O(N) walk
} while (link !== undefined);
}
return false;
}
Impact
For a computed signal with N producer dependencies, if any producer is re-read (e.g. in a second pass of a loop, or a computed that reads the same signal multiple times), the total work becomes:
$$\sum_{k=1}^{N} k = \frac{N(N+1)}{2} = O(N^2)$$
This is catastrophic for real-world applications with large collections. Our application wraps ~50,000 domain objects in individual signals and has computed signals that iterate over them. After upgrading from Angular v19 to v20+:
| Metric |
Angular v19 (arrays) |
Angular v21 (linked lists) |
| Time to evaluate computed over 50k signals |
~20ms |
~10-12 seconds |
| Degradation factor |
— |
~500x slower |
| Behavior over time |
Stable |
Gets progressively worse |
Critical detail: The regression only triggers on live computeds (those with downstream consumers — e.g., template bindings, effects). Non-live computeds create extra link objects during first eval, which are reused via the O(1) incremental-rebuild fast path. This is why naive benchmarks may miss the issue.
Please provide a link to a minimal reproduction of the bug
https://stackblitz.com/edit/angular-v21-starter-zfpyrpxq?file=src%2Fapp%2Fapp.ts,package.json,README.md
Please provide the exception or error you saw
No exception — this is a performance regression, not a crash.
The application becomes unusable due to multi-second signal evaluation times.
Please provide the environment you discovered this bug in (run ng version)
Angular CLI: 21.2.0
Node: 24.13.1
Package Manager: npm 11.8.0
OS: linux x64
Angular: 21.2.0
... animations, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router
Package Version
---------------------------------------------------------
@angular/cli 21.2.0
@angular/core 21.2.0
typescript 5.8.3
Anything else?
Suggested fix: The isValidLink() function should not perform a linear scan. Possible approaches:
Replace the O(N) walk with a version number or epoch check on the link to validate it in O(1). For example, stamp each link with the consumer's current computation epoch — if they match, the link is valid.
The original PR description says the change was for "faster signal creation." However, signal creation is typically a one-time cost, while signal reads happen continuously. The tradeoff of faster creation for O(N²) reads is harmful for applications with large reactive graphs.
Related issues:
Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
Yes
Description
PR #62284 (commit 3c008c9) introduced an O(N²) performance regression for computed signals that depend on many producers.
The refactor from arrays to linked lists in the reactive graph (
producerNode[]→producers/producersTaillinked list) introduced a functionisValidLink()that performs a linear scan of the entire producer linked list every time a previously-tracked signal is re-read during a recomputation.In Angular v19 (arrays), re-reading an already-tracked producer was O(1) via indexed array access. In v20+ (linked lists),
isValidLink()walks fromconsumer.producers(head) through the chain to findcheckLink, making each re-read O(K) where K is the position in the list. This compounds to O(N²) for N producers.The problematic code path
In
producerAccessed():isValidLink()walks the entire linked list from head to tail:Impact
For a computed signal with N producer dependencies, if any producer is re-read (e.g. in a second pass of a loop, or a computed that reads the same signal multiple times), the total work becomes:
This is catastrophic for real-world applications with large collections. Our application wraps ~50,000 domain objects in individual signals and has computed signals that iterate over them. After upgrading from Angular v19 to v20+:
Critical detail: The regression only triggers on live computeds (those with downstream consumers — e.g., template bindings, effects). Non-live computeds create extra link objects during first eval, which are reused via the O(1) incremental-rebuild fast path. This is why naive benchmarks may miss the issue.
Please provide a link to a minimal reproduction of the bug
https://stackblitz.com/edit/angular-v21-starter-zfpyrpxq?file=src%2Fapp%2Fapp.ts,package.json,README.md
Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)Anything else?
Suggested fix: The
isValidLink()function should not perform a linear scan. Possible approaches:Replace the O(N) walk with a version number or epoch check on the link to validate it in O(1). For example, stamp each link with the consumer's current computation epoch — if they match, the link is valid.
The original PR description says the change was for "faster signal creation." However, signal creation is typically a one-time cost, while signal reads happen continuously. The tradeoff of faster creation for O(N²) reads is harmful for applications with large reactive graphs.
Related issues: