What track $index actually costs in Angular's @for

Prepending a row costs both track expressions exactly one DOM insertion, but track $index shifts every row's typed text onto its neighbour; shuffling does zero structural DOM work under track $index and puts most rows' state on the wrong row
COMPANION REPO · LIVE FROM GITHUB

When @for arrived, track came with it as a requirement rather than an option. Leave it off and the build stops:

NG5002: @for loop must have a "track" expression

That is a good design and it has one predictable consequence. Faced with a compile error, a lot of people reach for the shortest thing that makes it go away, and the shortest thing is track $index. It compiles, the list renders, the tests pass, and nothing looks wrong.

I wanted to know what it actually costs, so I built two columns rendering the same array with the same row component, differing only in the track expression. Each row carries the two kinds of state that real rows carry: a field on the component instance, and an <input> whose value lives in the DOM element and which Angular never writes back. Row constructions are counted by the rows; DOM insertions and removals are counted by a MutationObserver on each list, so the numbers are the browser's account rather than Angular's. Measured on Angular 21.2, the newest LTS line; the @for reconciliation behind these numbers is unchanged in 22.1, the current active release.

PREPEND ONE ROW · THE CHIP IS WHAT THE USER TYPED
@for (row of rows(); track $index)
the view belongs to the position — it stays, the data moves through it
@for (row of rows(); track row.id)
the view belongs to the row — it moves with it
both columns inserted exactly one DOM node — the cheap one is the one that got every row wrong

Prepending costs them the same, structurally

Six rows. I typed a tag into every input saying which row it belonged to, pressed a counter on the first row, then prepended one row.

                         track $index        track row.id
DOM nodes added                     1                   1
new row constructed                 1                   1
existing rows rebuilt               0                   0
row #100 "Ada" afterwards    someone else's       its own

Identical structural DOM work: one node inserted in both columns, and in both cases the one new row is the only one actually constructed — no existing row is destroyed and rebuilt. What isn't identical: under track $index all six existing positions get rebound to a different row's data, so Angular rewrites six ids and six labels; under track row.id the six existing rows keep the exact same object reference they had before the prepend, so nothing about them needs rewriting at all. Same element count, different amount of binding work underneath it.

The difference that matters, though, is where the state ended up. Under track row.id the new row is the one that appeared at the top: empty input, and every one of the six existing rows kept its own text. Under track $index the top position is not new — it is the same element Ada's row always was, now rebound to the new row's data, so it shows the text I had typed into Ada. One position down, the element that used to be Grace's is now labeled Ada and shows the text I had typed into Grace, and so on down the list; the element that is actually newly constructed sits at the bottom, and it is the only empty input in that column.

This is the part that the usual advice gets backwards. track $index is not being slower here. It does exactly the same structural DOM work as the correct version and produces a wrong answer, which is a much harder failure to notice in a review than a slow one.

The counter I pressed on the first row before prepending tells the same story from the component side. Under track $index it comes back attached to the top row — which is now the new row, showing "note 1" for a click it never received. Under track row.id the count stays on Ada, wherever she ends up.

Shuffling costs it zero structural work

The second measurement makes it sharper. Same six rows, same tags, then a shuffle — one run, captured live:

                         track $index        track row.id
DOM nodes added                     0                   4
DOM nodes removed                   0                   4
rows showing another row's state  5 of 6            0 of 6

Tracking by index did no structural DOM work at all: no element was inserted or removed, though Angular still wrote new text into the existing nodes as it rebound each position — that part is real DOM work too, just not the kind a childList observer counts. Every element stayed exactly where it was, which is the cheapest possible outcome for element churn and also completely wrong: all six inputs kept the values belonging to whoever used to occupy that position.

Tracking by id moved four nodes here, and every row kept its own state. That move count and the "5 of 6" figure both depend on the specific shuffle — on how many rows happen to land back where they started. One row (Edsger, in this run) landed on its own original position by chance, which is why it is 5 of 6 and not 6 of 6. Re-run it and you might see a different move count, or every row wrong, or two rows still right. Track $index is always zero structural DOM operations for a reorder, full stop; the number of rows it gets wrong depends on the permutation, reaching a full 6 of 6 only when no row happens to land back on its own position, which happens about 37% of the time on six rows (265 of the 720 permutations are full derangements), so it is a common outcome, just not a guaranteed one.

So the honest framing is not "index is slow, id is fast". It is that index buys you cheapness with the identity of your rows, and you only find out you spent it when something in a row holds state that no binding controls — a half-typed edit, a focused field, an expanded section, a running transition, scroll position inside the row.

Why bindings hide it

The reason this survives testing is that anything driven by a binding looks fine. The label re-renders, the id re-renders, everything you can see in the template follows the data, because Angular updates the bindings on the reused view.

What does not follow the data is everything Angular does not own: the value of an uncontrolled <input>, fields on the component instance, DOM focus, CSS transitions already in flight. Those belong to the view, and under track $index the view belongs to the position.

A list of plain, read-only text with nothing else attached to its elements — no animation, no external code holding a reference to a row — is genuinely unaffected. That is worth saying plainly, because it is why the habit spreads: for the first few lists it does no harm at all.

The opposite mistake

Reaching for object identity is the natural correction, and it has a failure mode of its own:

@for (row of rows(); track row) { … }

That is correct as long as the objects survive. The moment something hands you a new array of equal-but-new objects, every row is a stranger. A refetch, a map in a resolver, a structuredClone, an immutable update that copies more than it needed to — any of those, and the whole list is destroyed and rebuilt. Angular has a diagnostic for exactly this case: NG0956, warning that the track expression caused the DOM structure to be re-created. It only fires for rows complex enough that recreating them is worth warning about. Angular's own threshold is more than two template declarations per row, so a trivial <li>{{ item }}</li> never triggers it, even though it is just as vulnerable. Same dev-mode-only scoping as the duplicate-key warning below.

Three refetches of a three-row list, where the data does not change at all and only the object references do:

                        track row      track row.id
rows rebuilt                    9                 0
DOM nodes added                 9                 0
DOM nodes removed               9                 0
typed text afterwards        lost              kept

Nine rebuilds for data that never changed. The same failure that track $index produces silently, this one produces loudly — and on a list of any realistic size it is the one that actually shows up in a profile.

What to reach for

Track the thing that identifies the row in your domain, not in your array. A database id, a slug, an order number, a composite key you build once when the data arrives. It survives reordering, refetching, and copying, which is the whole job.

Two things to watch for while you do it. The key has to be unique, and Angular will tell you when it is not:

NG0955: The provided track expression resulted in duplicated keys for a given
collection. … Duplicated keys were: key "100" at index "0" and "1".

That is a development-mode diagnostic. I checked both builds: the same duplicate key produced this warning under a development build and absolutely nothing under a production one. So it is a message you get while you are working, which is the right place for it, and not one that will find the problem for you after release.

The other thing: if your data has no stable id at all, that is worth fixing at the edge where the data enters rather than in the template. Assign one when you map the response, and every list downstream inherits it.

track $index is fine when the position genuinely is the identity. A fixed set of columns, a numbered list of static strings, rows that only ever get appended and never reordered or removed. If the array can be sorted, filtered, paged, or prepended, it is not.

Try it rather than trust it

The measurements above are from github.com/gdhami-net/for-track-demo: type into a few rows, press a counter, then prepend or shuffle and watch the state slide sideways in one column and stay put in the other. It takes about fifteen seconds and it is much more convincing than a table.

If you have @for blocks that were migrated from *ngFor in a hurry, grepping for track $index is a five-minute job with a good chance of finding a bug nobody has reported yet, because the symptom — "sometimes the wrong text is in the box" — is one users assume they imagined.