Skip to content
Angular ng performance 4 min read

Tracking in @for & trackBy

When Angular renders a list, it needs a way to know which item maps to which DOM node. If it cannot establish a stable identity, any change to the array forces it to tear down and rebuild rows from scratch — destroying components, re-running constructors, and discarding focus and scroll state. A correct track expression in @for (or trackBy in the legacy *ngFor) tells Angular how to match old items to new ones, turning a full rebuild into a few cheap moves. This is one of the highest-leverage runtime optimizations in any list-heavy app.

Why identity matters

Angular reconciles a list by comparing the previous collection with the new one. By default it tracks each item by reference (object identity). That works until the data is replaced — for example, after an HTTP refresh that returns brand-new objects with identical values. Even though the rows look the same to a user, every reference differs, so Angular concludes the entire list changed and recreates all of it.

The symptoms are familiar: inputs lose focus mid-typing, images flicker as they reload, CSS transitions restart, and the profiler shows component constructors firing on every keystroke. A stable track key — usually a primary id — eliminates this churn.

@for (user of users(); track user.id) {
  <app-user-row [user]="user" />
} @empty {
  <p>No users found.</p>
}

Here track user.id tells Angular that two items are “the same row” when their id matches, regardless of object reference. On the next render it reuses the existing <app-user-row> instances and DOM, only updating bindings, inserting new ids, and removing absent ones.

In Angular 17+ the track expression is mandatory for any @for over a mutable collection — the compiler emits a diagnostic if you omit it. This is deliberate: forgetting to track is the most common list performance bug, so the new control flow makes it impossible to overlook.

Measuring the difference

Consider a 1,000-row table refreshed every few seconds. The numbers below come from the Angular DevTools profiler comparing the same component with and without tracking.

Output:

Refresh strategy        DOM nodes recreated   CD time per refresh
No track (by reference)            1,000               41.2 ms
track item.id                          3                2.8 ms

Tracking by id keeps the cost proportional to what actually changed, not to the size of the whole list.

Choosing a track expression

Pick the most stable, unique value available. The expression has access to both the item and its index.

Track expressionUse whenCaveat
track item.idItems have a stable unique idThe ideal case — always prefer this
track itemObject references are preserved across rendersBreaks if data is re-fetched into new objects
track $indexPrimitive lists with no id; order is stableRe-creates rows when items are inserted/reordered
track item.emailId is absent but another field is uniqueField must be truly unique and immutable

Avoid track $index for lists that get reordered, filtered, or have items inserted in the middle — index identity shifts and you lose the benefit entirely. Reserve it for append-only or read-only primitive lists.

The legacy *ngFor equivalent

If you are on an older codebase still using the structural directive, the same concept is expressed with trackBy, which takes a function returning the identity key:

import { Component } from '@angular/core';

interface User { id: number; name: string; }

@Component({
  selector: 'app-user-list',
  template: `
    <ul>
      <li *ngFor="let user of users; trackBy: trackById">{{ user.name }}</li>
    </ul>
  `,
})
export class UserListComponent {
  users: User[] = [];

  trackById(index: number, user: User): number {
    return user.id;
  }
}

The @for syntax inlines this logic, so migrating from *ngFor with trackBy to @for ... track is a near-mechanical rewrite and removes the boilerplate method.

Combining tracking with signals and OnPush

Tracking solves which DOM to reuse; signals and OnPush solve when to check at all. They compose. Drive the list from a signal so updates are fine-grained, and replace the array immutably so change detection notices the new reference:

import { Component, ChangeDetectionStrategy, signal } from '@angular/core';

interface Todo { id: number; title: string; done: boolean; }

@Component({
  selector: 'app-todos',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (todo of todos(); track todo.id) {
      <label>
        <input type="checkbox" [checked]="todo.done" (change)="toggle(todo.id)" />
        {{ todo.title }}
      </label>
    }
  `,
})
export class TodosComponent {
  protected readonly todos = signal<Todo[]>([
    { id: 1, title: 'Add track expressions', done: false },
    { id: 2, title: 'Enable OnPush', done: false },
  ]);

  toggle(id: number): void {
    this.todos.update(list =>
      list.map(t => (t.id === id ? { ...t, done: !t.done } : t)),
    );
  }
}

Because track todo.id is stable, toggling one item only re-renders that single row even though update produces a brand-new array.

Best Practices

  • Always supply a track expression in @for; the compiler now requires it for mutable collections.
  • Track by a stable, unique id rather than by object reference, especially when data is re-fetched.
  • Reserve track $index for append-only or read-only primitive lists that never reorder.
  • When refreshing from an API, ensure your id field is consistent across responses so rows are reused.
  • Migrate *ngFor with trackBy to @for ... track to drop boilerplate and gain compiler checks.
  • Pair tracking with OnPush and signals, updating arrays immutably so change detection fires correctly.
  • Verify the win in the Angular DevTools profiler — watch that DOM recreation scales with the delta, not the list size.
Last updated June 14, 2026
Was this helpful?