Taming AngularJS Digest Lag with One-Time Bindings in ng-repeat
Large ng-repeat views in AngularJS 1.x slow down because every binding is watched on each digest. One-time bindings with :: deregister watchers after the first stable value, cutting digest work for render-once data.
27 Feb 2026, 15:52 UTC

An ng-repeat with a few hundred rows and three bindings per row can make an AngularJS 1.x view feel sluggish on every keystroke. The useful takeaway is not to rewrite the app, but to stop Angular from watching data that is rendered once and never changes. One-time bindings with the :: prefix deregister their watchers after the first stable value, which cuts digest-cycle work in read-only lists without changing the data model.
Why watcher count dominates the digest
AngularJS evaluates every watcher on every digest cycle until the model stabilizes. A watcher is created for each interpolation like {{value}} and for each directive that binds to a scope expression. In an ng-repeat, watchers multiply by the number of items. The cost is not DOM creation; it is the repeated evaluation of expressions during digests triggered by user input, timers, or $http callbacks.
The digest is the internal loop AngularJS uses to propagate model changes to the view. More watchers means more work per loop, and in classic 1.x apps watcher count is the primary scaling bottleneck.
How :: changes the watcher lifecycle
One-time binding syntax was introduced in AngularJS 1.3. It works in interpolations like {{::value}}, in directive attributes like ng-bind="::value", and in ng-repeat expressions such as ng-repeat="item in ::items".
The watcher behaves like a normal binding until the expression first yields a non-undefined value. Once a stable value is produced, AngularJS deregisters the watcher. Until that moment the binding still watches, so asynchronously loaded data renders correctly when it arrives.
Important version note: :: is unavailable before 1.3. Check the version in the browser dev console on the page running the app:
angular.version.full
No special permissions are required. Expected result: a version string such as 1.6.9. If the major.minor is below 1.3, one-time bindings cannot be used and will be parsed as ordinary (broken) expressions.
Where it helps and where it silently breaks
One-time bindings are ideal for read-only or render-once data: static labels, reference tables, user profile fields displayed after load, and large lists whose items do not mutate after initial render.
They are harmful when applied to data that must update. A live counter, a form-bound model, a filtered list that re-sorts, or any value that changes after first render will freeze at the first value with no error. That silent freeze is a common bug source in legacy code.
A collection bound with :: still watches until the collection reference is defined. Reassigning the array later, e.g. $scope.items = newArray, will not update the view because the watcher has already been removed.
Worked example for a read-only table
Assume a legacy view renders a product list that is loaded once from the server and never edited in place:
<table>
<tr ng-repeat="product in ::products track by product.id">
<td ng-bind="::product.sku"></td>
<td ng-bind="::product.name"></td>
<td ng-bind="product.price"></td>
</tr>
</table>
::products removes the collection watcher once the array is defined. ::product.sku and ::product.name are render-once columns. product.price stays a normal binding because price can change via a live update. This is an illustrative pattern, not a tested benchmark.
Combining one-time bindings with track by and pagination or limitTo reduces both watcher count and DOM churn, giving the largest practical wins without architectural changes.
Practical verification for legacy maintenance
AngularJS reached end of life in December 2021 and receives no security fixes. This guidance is for legacy maintenance, not new projects.
A practical way to check the effect is to compare watcher counts before and after applying ::. In the browser console, after the view has stabilized, you can count watchers recursively from the root scope:
function countWatchers(root) {
var count = 0;
(function walk(scope) {
if (!scope) return;
count += (scope.$$watchers || []).length;
var child = scope.$$childHead;
while (child) { walk(child); child = child.$$nextSibling; }
})(root);
return count;
}
var el = document.querySelector('[ng-app]') || document.body;
countWatchers(angular.element(el).scope().$root);
Run this in dev tools on the page; no special permissions are needed. Expected check: the returned number drops after static columns are converted to ::. The exact reduction depends on list size and bindings per row.
Toggle a model value after initial render to confirm behavior: fields with :: should stay frozen while normal bindings update. This confirms watcher deregistration.
Limitations to remember: a one-time binding does not prevent the initial digest cost, it only removes future work. It also does not help if the bottleneck is DOM size or slow filters rather than watcher count.
Actionable closing: audit the heaviest ng-repeat views, identify columns that are truly render-once, apply :: to those bindings only, keep live columns as normal bindings, and pair the change with track by and pagination. Document which bindings are frozen so future maintainers do not expect live updates.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.