TL;DR: Angular signals trigger change detection on reference changes, even when data hasn’t changed. Use normalized values, leverage Angular’s template optimizations, and build debugging tools to catch wasteful updates.
Prerequisites
Before we dive in, it helps if you’re already familiar with:
- Angular signals (the basics)
- How Angular’s change detection works at a high level (https://angular.love/the-latest-in-angular-change-detection-zoneless-signals)
Angular signals revolutionized reactivity in Angular applications, but they come with a subtle performance gotcha that many developers miss. In this post, we’ll explore how reference changes in signals can trigger unnecessary change detection cycles, build a debugging tool to catch these issues, and learn practical strategies to optimize our signal usage.
The Problem: Reference vs. Structural Changes
Here’s the catch: what if a signal’s reference changes, but the actual data inside it hasn’t budged? You’re basically burning CPU on pointless change detection. I tried a little experiment with deep equality checks, and the results… let’s just say, they were kinda unexpected (and yes, this was on a real app, not some toy “hello world” demo).
This is what happens when the signal value is set:
function signalSetFn(node, newValue) {
if (!node.equal(node.value, newValue)) {
node.value = newValue;
signalValueChanged(node);
}
}
The node.equal
by default is Object.is
(until custom is provided). I.e., Object.is([], [])
is false
, though it’s essentially the same value (the change detection would update nothing in the template).
Note that it’s not about slapping { equal: ... }
when creating signals; it’s more about normalizing signal updates. The native solution would be to use isEqual
from lodash-es
, but it’s kinda overkill most of the time — especially when our signals are just primitives or references, where Object.is
is already fast enough. Usually, the smarter move is just to design our signals better.
How Angular’s Zoneless Change Detection Works
In Angular’s zoneless mode, signals are basically what kick off change detection. Whenever a component uses a signal and it changes, it ends up hitting consumer.consumerMarkedDirty
, which, triggers the next steps:
const REACTIVE_LVIEW_CONSUMER_NODE = {
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
kind: 'template',
consumerMarkedDirty: (node) => {
markAncestorsForTraversal(node.lView);
},
consumerOnSignalRead() {
this.lView[REACTIVE_TEMPLATE_CONSUMER] = this;
},
};
Here, markAncestorsForTraversal
walks up the component tree and marks all affected views dirty:
function markAncestorsForTraversal(lView) {
lView[ENVIRONMENT].changeDetectionScheduler?.notify(0 /* NotificationSource.MarkAncestorsForTraversal */);
// Additional implementation details omitted for brevity
}
If zone.js is enabled, Angular ignores notifications from listeners:
class ChangeDetectionSchedulerImpl {
notify(source) {
if (!this.zonelessEnabled && source === 5 /* NotificationSource.Listener */) {
return;
}
// Additional implementation details omitted for brevity
}
}
Otherwise, Angular schedules change detection:
this.cancelScheduledCallback = scheduleCallback(() => this.tick());
Building a Dev Tool to Detect Wasteful Updates
What if we could have a devtool that lets us track whether a signal is being updated with the same object, but with a different reference?
What I did is the following: I patched the core of the package (the signal.mjs
file).
const producerToConsumersMap = new WeakMap();
function producerAccessed(node) {
if (inNotificationPhase) {
throw new Error(typeof ngDevMode !== 'undefined' && ngDevMode ? `Assertion error: signal read during notification phase` : '');
}
if (activeConsumer === null) {
// Accessed outside of a reactive context, so nothing to record.
return;
}
if (!ngServerMode && ngDevMode) {
const consumers = producerToConsumersMap.get(node) ?? new Set();
consumers.add(activeConsumer);
producerToConsumersMap.set(node, consumers);
}
activeConsumer.consumerOnSignalRead(node);
// Additional implementation details omitted for brevity
}
ngServerMode
andngDevMode
are compile-time variables.
We tracked producers ↔ consumers in dev mode using a WeakMap
. Every time a signal is read, we’re linking a signal to the currently active consumer; a consumer might be anything, a component, a computation (linkedSignal
, computed
), or an effect. There might be many consumers for a single producer (using a set to filter identical consumers).
To measure redundant signal updates, I patched signalSetFn
in dev mode. The goal: detect cases where the signal’s reference changed but its deep value didn’t, and see if Angular’s change detection could have been avoided.
Now, we need to update the signalSetFn
:
function signalSetFn(node, newValue) {
if (!producerUpdatesAllowed()) {
throwInvalidWriteToSignalError(node);
}
if (
!ngServerMode &&
ngDevMode &&
globalThis.isEqual &&
// If there are no consumers for this node, skip the check.
producerToConsumersMap.get(node) &&
// Ensure both old and new values exist.
node.value &&
newValue &&
// If the reference actually changed...
node.value !== newValue &&
// ...and both are objects (so equality comparison makes sense),
typeof node.value === 'object' &&
typeof newValue === 'object' &&
node.equal !== globalThis.isEqual
) {
// Measure how long the equality comparison takes.
const t0_isEqual = performance.now();
const equal = globalThis.isEqual(node.value, newValue);
const t1_isEqual = performance.now();
// If values are deeply equal, we can test if Angular’s change detection
// could have been avoided (or optimized away).
if (equal) {
// Retrieve all consumers (components, computations, signals, etc.) that depend
// on the given producer `node`.
const consumers = producerToConsumersMap.get(node);
// Iterate over all consumers that depend on this signal.
for (const consumer of consumers.values()) {
// `consumer.view` is an effect.
const lView = consumer.view ?? consumer.lView;
// If consumer is not an Angular component (e.g. just a linked signal),
// it won't have an `lView`, so skip.
if (!lView) continue;
// Index 9 of LView holds the injector reference.
const injector = lView[9];
if (!injector) continue;
const applicationRef = injector.get(globalThis.ApplicationRef, null);
const changeDetectorRef = injector.get(globalThis.ChangeDetectorRef, null);
if (!applicationRef || !changeDetectorRef) continue;
const oldValue = node.value;
// Trigger change detection only when there's no `tick()` running.
applicationRef.whenStable().then(() => {
// Measure how long a full Angular change detection cycle takes.
const tick_t0 = performance.now();
let error = false;
try {
// Mark for check from that child component the consumer is linked to.
changeDetectorRef.markForCheck();
// Trigger change detection manually.
applicationRef.tick();
} catch {
error = true;
} finally {
// Skip comparison if the change detection has errored.
if (error) return;
const tick_t1 = performance.now();
const isEqualTime = t1_isEqual - t0_isEqual;
const changeDetectionTime = tick_t1 - tick_t0;
const cheaper = isEqualTime < changeDetectionTime;
console.table([
{
isEqualTime,
changeDetectionTime,
oldValue,
newValue,
producer: node,
consumer,
cheaper: cheaper ? '✅' : '❌',
},
]);
}
});
}
}
}
if (!node.equal(node.value, newValue)) {
node.value = newValue;
signalValueChanged(node);
}
}
The patch adds a dev-mode guard that:
- Checks whether the reference changed but the deep value stayed the same (using
globalThis.isEqual
). - Iterates over all consumers (components, computed signals, effects).
- Measures how long a full Angular change detection cycle would take versus a deep equality check.
- Logs whether a deep equality check would have been cheaper than running CD (change detection).
Basically, this little dev-mode tweak is like a radar for wasted signal updates. It flags when Angular is doing a CD pass even though the signal’s actual content hasn’t changed.
We could use setPostSignalSetFn
, but we wouldn’t have access to the current and new values.
Let’s also expose those things on the globalThis
, this can be done in main.ts
:
import { ApplicationRef, ChangeDetectorRef } from '@angular/core';
import { isEqual } from 'lodash-es';
if (ngDevMode) {
Object.assign(globalThis, { isEqual, ChangeDetectorRef, ApplicationRef });
}

The screenshot shows that the data
signal was updated with a list that has the same shape, but different references. isEqualTime
is zero, because the measured operation completes faster than the precision of the timer — meaning it took less than 1 microsecond.
Practical Solutions & Best Practices
- Signals fire change detection whenever their reference flips, even if the actual data didn’t change. Watch out, these updates add up.
- Primitives or computed signals = cheap. Angular can skip unnecessary updates when using
Object.is
. - Deep equality checks are cool for dev mode, but don’t ship them to prod unless you really need them.
- Smarter signal design is the secret sauce. Break your state into smaller signals or use computed values so CD doesn’t run for nothing.
Template gotchas
If you do something like:
[filterPills]="state.pills() || []"
Angular is actually smart here. The []
isn’t created fresh on every CD run — the compiler lifts it into a ɵɵpureFunction
. That means as long as pills()
keeps returning null
or undefined
, Angular reuses the same empty array reference and won’t trigger a new binding update every cycle.
So in this case, just writing || []
directly in the template can actually be better than wrapping it in a computed
that returns []
, because Angular’s template compiler does the caching for us:
class App {
readonly filterPills = computed(() => this.state.pills() ?? []);
}
Note: if the pills()
changes from null
→ undefined
, the computed will detect a change because the returned value switches from []
→ []
(new empty array). Even though they’re both „empty”, the computed sees the reference changed and will return a new array. That would trigger Angular change detection unnecessarily.
const EMPTY_ARRAY = [];
class App {
readonly filterPills = computed(() => this.state.pills() ?? EMPTY_ARRAY);
}
Now, if pills()
is null
or undefined
, it always returns the same EMPTY_ARRAY
reference, avoiding redundant change detection.
Whenever the signal is called again in the template during a change detection cycle, the child component’s bindingUpdated
returns false
, because it also uses Object.is
to check whether the binding has changed. As a result, the child component won’t be marked as dirty (to be checked).
That debugging tool also helped us identify many spots in the application where signal updates could be normalized. For example, this commit reduced the number of NGXS (a state management library for Angular) global state updates by preventing unnecessary router state changes (commit). Angular’s router fires navigation events even when navigating to the same route with an identical state. This was causing NGXS to update its internal signals unnecessarily, triggering change detection across all components using selectSignal
.
Selector normalizations
export const getFilterPills = createSelector([getCriteria], (criteria) => {
const pills: FilterPill[] = [];
if (criteria['...']) {
pills.push('...');
}
return pills;
});
In this case, if the criteria
object changes but pills
always evaluates to an empty array, it would still trigger a signal recalculation. To avoid that, we can normalize the return value:
const EMPTY_PILLS: FilterPill[] = [];
export const getFilterPills = createSelector([getCriteria], (criteria) => {
const pills: FilterPill[] = [];
if (criteria['...']) {
pills.push('...');
}
return pills.length === 0 ? EMPTY_PILLS : pills;
});
Conclusion
Signal optimization might seem like premature optimization, but in large Angular applications, these reference changes can cascade into hundreds of unnecessary change detection cycles and components being checked. The debugging tool we built helps identify these hotspots, while the normalization patterns ensure your signals only trigger updates when the data actually changes. Start with measuring, then optimize where it matters most.