Cutting Digest Cost in Legacy AngularJS with One-Time Binding (::)
Large AngularJS 1.x lists lag because every digest re-evaluates thousands of watchers. One-time binding (::) deregisters watchers for static data — here's how it works, a worked ng-repeat example, and the staleness trade-off.
13 Feb 2026, 23:15 UTC

You have a legacy AngularJS 1.x page that renders a few thousand rows — an invoice list, an audit log, search results — and every interaction feels sticky. Typing in a filter box lags, clicks take a beat to respond. The usual culprit is not the DOM itself but the digest cycle: AngularJS re-evaluates every active watcher on every digest, and a page with thousands of bindings has thousands of watchers. The useful takeaway: for data that never changes after render, one-time binding syntax (::) deregisters those watchers and can cut digest work dramatically, with a one-line change per binding.
Why watcher count is the bottleneck
AngularJS's change detection is a dirty-checking loop. On each digest, the framework walks the scope tree and re-runs every registered watch expression, comparing the result to the previous value. A binding like {{ row.name }} inside an ng-repeat registers one watcher per row. A 2,000-row table with three interpolated fields per row carries roughly 6,000 watchers, and every keystroke anywhere on the page re-evaluates all of them.
This is the dominant scaling limit in classic AngularJS apps. Optimizing controllers or templates helps at the margins, but reducing the number of live watchers is what moves the needle.
What :: actually does
Introduced in AngularJS 1.3, one-time binding prefixes an expression with two colons:
<tr ng-repeat="row in ::rows track by row.id">
<td>{{::row.name}}</td>
<td>{{::row.status}}</td>
</tr>
The watcher stays registered until the expression first produces a defined value, then AngularJS deregisters it permanently. From that point on, the digest cycle skips it entirely. In the table above, once rows is populated, the repeat watcher and both per-row interpolation watchers are removed — roughly three watchers per row gone.
Check your version before relying on this: :: does not exist in 1.2 or earlier. Look at the version header in your served angular.js or the dependency entry in package.json. On older versions you would need an upgrade or a bind-once-style custom directive.
A worked example: the 2,000-row log viewer
Say you render an audit log fetched once from the server:
<input ng-model="query" placeholder="Filter...">
<table>
<tr ng-repeat="entry in ::entries track by entry.id">
<td>{{::entry.timestamp | date:'short'}}</td>
<td>{{::entry.actor}}</td>
<td>{{::entry.action}}</td>
</tr>
</table>
Before the change, each of the 2,000 rows contributes multiple watchers, and every character typed into the filter input triggers a digest that re-evaluates all of them — even though the log data is static. After adding ::, those watchers deregister once the data arrives, and the digest only has to process the query binding and whatever else is genuinely live.
Note the deliberate choice to keep track by entry.id: it prevents AngularJS from destroying and recreating DOM nodes when the collection reference changes, which is a separate but complementary win. Also note the date filter inside the interpolation — filters re-run with the expression, but since this binding is one-time, it only runs once.
Measuring whether it worked
Don't take the improvement on faith; measure it. Two practical checks, both run in the browser console on the page in question:
- Watcher count: walk the scope tree and sum
$$watcherslengths, or use a debugging tool like Batarang. Compare the count before and after adding::. You should see it drop by roughly the number of bindings you converted, once data has loaded. - Digest duration: time a digest explicitly, e.g. wrap
$rootScope.$digest()(or$applyfrom an outside event) withconsole.time()/console.timeEnd()in your browser's devtools console. Run it with the same data set before and after the change.
Results depend on your hardware and data, so measure on a representative machine rather than assuming a fixed speedup.
The trade-off: frozen values and silent staleness
One-time binding is a commitment: the rendered value will never update again. That makes it right for append-once or static data — logs, invoices, historical reports, search results — and wrong for anything live. Two failure modes deserve emphasis:
- Mutated-later data goes silently stale. If a field is updated after initial render (an async enrichment call, an edit action), a one-time-bound UI shows the old value with no error or warning. These bugs are hard to trace because nothing crashes.
- Initially-undefined values keep watching. If the expression is
undefinedon the first digests (data hasn't arrived yet), the watcher stays alive until it becomes defined. That's usually what you want, but it surprises developers who expect immediate deregistration — and an expression that never resolves keeps watching forever.
A practical rule: only apply :: to data you can guarantee is immutable for the lifetime of the view. If you're unsure, leave the binding live.
Complementary moves, and a caveat about the platform
One-time binding pairs well with other digest-reduction decisions: track by to avoid DOM churn, pagination or virtual scrolling to cap the number of rendered rows in the first place, and moving filters out of repeated expressions (precompute values in the controller instead of piping in every row).
The honest caveat: AngularJS 1.x is end of life — the final release line is 1.8.x and it receives no security patches. One-time binding is a legitimate maintenance technique for applications you're keeping alive, but it is not a reason to start anything new on AngularJS. If you're stuck on a legacy codebase, auditing your largest ng-repeat lists and converting read-only bindings to :: is one of the cheapest, lowest-risk performance wins available — measure the watcher count, confirm the data is truly static, and ship it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.