Prerequisites

To get the most out of this discussion, you should already have a handle on:

Note: The Best Practices article is especially helpful for spotting common patterns and avoiding typical mistakes. The same author has published several other pieces on signals that are well worth reading for a fuller picture of signals in Angular.

Signals have brought a major shift to how reactivity works in Angular applications, but there is a performance subtlety that tends to slip under the radar. In the following sections, we’ll look at how reference changes within signals can lead to redundant change detection work, construct a debugging utility to surface these problems, and examine practical ways to fine-tune signal usage.

Angular Signals: The Hidden Cost of Reference Changes — figure 1

The Core Issue: References vs. Content

Imagine a signal whose reference changes, but whose underlying content is unchanged. In that case, you’re spending processing power on change detection that doesn’t accomplish anything. A quick experiment with deep equality checks produced some surprising results—and this was tested on a production application, not a trivial demo.

Here’s what happens at the moment a signal’s value is assigned:

function signalSetFn(node, newValue) {
 if (!node.equal(node.value, newValue)) {
   node.value = newValue;
   signalValueChanged(node);
 }
}

By default, node.equal relies on Object.is unless a custom comparison function is supplied. That means Object.is([], []) evaluates to false even when the two arrays are conceptually empty—nothing in the template would actually need to update, yet the comparison still reports a difference.

The answer isn’t just throwing an { equal: ... } option onto every signal creation. It’s more effective to normalize how updates are delivered. For instance, isEqual from lodash-es could act as a native solution, but it’s often overkill—especially when the signals only carry primitives or references, where Object.is is already performant. A better strategy is usually to improve how the signals themselves are structured.

Angular’s Zoneless Change Detection in Action

In zoneless mode, signals serve as the primary trigger for change detection. When a component reads a signal and it changes, the process calls consumer.consumerMarkedDirty, which initiates a chain of events:

const REACTIVE_LVIEW_CONSUMER_NODE = {
 ...REACTIVE_NODE,
 consumerIsAlwaysLive: true,
 kind: 'template',
 consumerMarkedDirty: (node) => {
   markAncestorsForTraversal(node.lView);
 },
 consumerOnSignalRead() {
   this.lView[REACTIVE_TEMPLATE_CONSUMER] = this;
 },
};

During this, markAncestorsForTraversal ascends the component hierarchy and marks every dependent view as dirty:

function markAncestorsForTraversal(lView) {
 lView[ENVIRONMENT].changeDetectionScheduler?.notify(0 /* NotificationSource.MarkAncestorsForTraversal */);
 // Additional implementation details omitted for brevity
}

With zone.js active, Angular ignores listener notifications:

class ChangeDetectionSchedulerImpl {
 notify(source) {
   if (!this.zonelessEnabled && source === 5 /* NotificationSource.Listener */) {
     return;
   }
   // Additional implementation details omitted for brevity
 }
}

Otherwise, change detection gets scheduled by Angular:

this.cancelScheduledCallback = scheduleCallback(() => this.tick());

A Debugging Tool for Spotting Unnecessary Updates

What if there was a way to inspect whether a signal gets assigned an equal object, but with a new identity? To make this possible, I applied a patch to the package’s core implementation—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
}

Both ngServerMode and ngDevMode are compile-time variables.

In dev mode, the relationship between producers and consumers was tracked through a WeakMap. Each time a signal is accessed, that signal gets linked to the currently active consumer. The consumer can be a component, a computation (linkedSignal, computed), or an effect. Multiple consumers can rely on the same producer, so a set is used to filter out duplicate entries.

To identify repeated signal assignments, I also patched signalSetFn in dev mode. The goal was straightforward: determine when the signal’s identity shifted but its content didn’t, and evaluate whether Angular’s change detection was actually necessary.

The next step is modifying 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);
 }
}

This patch introduces a dev-only guard that:

  • Verifies whether the reference updated while the deep value stayed constant (by calling globalThis.isEqual).
  • Loops through every consumer, including components, computed signals, and effects.
  • Compares the duration of a full change detection pass against the time needed for a deep equality check.
  • Logs a warning when the equality check would have been the less expensive route than running CD (change detection).

This dev-only tweak works like a detector for wasted signal updates. It highlights every instance where Angular runs change detection even though the signal’s payload hasn’t changed.

While setPostSignalSetFn exists, it wouldn’t give us access to both the previous and next values, which is why this approach was chosen.

These utilities can also be exposed globally, which is done in main.ts:

import { ApplicationRef, ChangeDetectorRef } from '@angular/core';
import { isEqual } from 'lodash-es';


if (ngDevMode) {
 Object.assign(globalThis, { isEqual, ChangeDetectorRef, ApplicationRef });
}
Angular Signals: The Hidden Cost of Reference Changes — figure 2

In the output, the data signal gets set to a list with the same structure but separate references. The isEqualTime value reads as zero because the operation finishes faster than the timer’s precision—meaning it took under a microsecond.

Practical Strategies and Recommendations

  • Signals will always invoke change detection when their reference changes, even if the data itself is identical. Be aware that these small triggers accumulate over time.
  • When a signal contains primitives or is derived via computed, it’s economical. Angular avoids extra work because Object.is correctly identifies duplicates.
  • Deep equality comparisons are acceptable in dev mode, but they shouldn’t be included in production builds unless there’s a compelling reason.
  • Designing signals thoughtfully goes a long way. Split state into more focused signals or use derived values so change detection doesn’t fire needlessly.

Template Pitfalls

If a template contains an expression like this:

[filterPills]="state.pills() || []"

Angular handles this efficiently. The [] doesn’t get recreated with each change detection cycle—the compiler hoists it into a ɵɵpureFunction. As long as pills() keeps returning null or undefined, the framework reuses that same empty array reference, preventing an extra binding update on every run.

In this situation, writing || [] right in the template might be preferable to placing it inside a computed that resolves to [], since Angular’s template compiler handles the caching automatically:

class App {
 readonly filterPills = computed(() => this.state.pills() ?? []);
}

One thing to watch out for: if pills() switches from null to undefined, the computed will see a difference because the value it returns changes from [] to []—that’s a brand-new array each time. While both represent emptiness, the computed sees the new reference and passes it along, which can force Angular into a needless change detection pass.

The fix: rely on a single immutable empty array reference

const EMPTY_ARRAY = [];


class App {
 readonly filterPills = computed(() => this.state.pills() ?? EMPTY_ARRAY);
}

With this adjustment, null or undefined inputs always produce the same EMPTY_ARRAY instance, so change detection stays quiet.

Each time a signal is accessed within a template during the change detection cycle, the child component’s bindingUpdated yields false since it also relies on Object.is to determine if the binding actually changed. Therefore, the child component will not be flagged as needing inspection.

A more polished alternative: build a reusable helper

To apply this pattern consistently across an entire project, it’s worth creating a small utility:

const EMPTY_ARRAY = Object.freeze([]);

export function computedArray<T>(
  source: () => T[] | null | undefined
): Signal<T[]> {
  return computed(() => {
    const value = source();
    return value?.length ? value : (EMPTY_ARRAY as unknown as T[]);
  });
}
Each time a signal is accessed within a template during the change detection cycle, the child component’s bindingUpdated yields false, because it also relies on Object.is to determine whether the binding actually changed. Consequently, the child component will not be marked for review.

That debugging tool also revealed numerous parts of the codebase where signal updates could be normalized. For instance, this commit cut down the number of global state updates from NGXS (a state management library for Angular) by filtering out redundant router state changes (commit). Angular’s router fires navigation events even when routing to the same path with an identical state. This prompted NGXS to refresh its internal signals pointlessly, leading to change detection across every component that depends on selectSignal.

Angular Signals: The Hidden Cost of Reference Changes — figure 3

Normalizing Selectors

export const getFilterPills = createSelector([getCriteria], (criteria) => {
 const pills: FilterPill[] = [];

 if (criteria['...']) {
   pills.push('...');
 }

 return pills;
});

In this situation, if the criteria object changes while pills consistently resolves to an empty array, a signal recalculation would still fire. To prevent that, the returned value can be normalized:

const EMPTY_PILLS: FilterPill[] = [];

export const getFilterPills = createSelector([getCriteria], (criteria) => {
 const pills: FilterPill[] = [];

 if (criteria['...']) {
   pills.push('...');
 }

 return pills.length === 0 ? EMPTY_PILLS : pills;
});

Closing Thoughts

Signal optimization could be dismissed as an unnecessary pursuit, but in big Angular applications, these identity shifts can ripple out into dozens of needless change detection runs and component checks. The debugging tool described here is useful for locating those trouble spots, and the normalization techniques help ensure signals remain quiet until the underlying data truly changes. Begin by measuring the behavior, then target the optimizations where they matter most.