Cutting Digest-Cycle Time in AngularJS Lists with One-Time Binding (::)
AngularJS 1.3+ one-time bindings (::) deregister watchers after first render, cutting digest-cycle time in large lists. A practical guide to applying, verifying, and safely reverting them.
23 Dec 2025, 03:37 UTC

The problem: digest cycles that scale with your watcher count
If a legacy AngularJS (1.x) page feels sluggish when rendering a large table or list, the usual culprit is watcher count. Every {{item.name}} interpolation, every ng-bind, and every ng-if registers a watcher, and each $digest evaluates all of them. A 500-row table with five bindings per row easily produces thousands of watchers, so even a trivial user interaction triggers a digest that walks all of them.
AngularJS 1.3 added one-time binding syntax — a :: prefix — that lets a binding render once and then deregister its watcher. Applied to data that never changes after first render, this can cut watcher counts dramatically without restructuring the application. This guide covers when it is safe, how to apply it, and how to verify the improvement.
Note: AngularJS reached end of life in December 2021 and receives no security patches. This technique is for maintaining existing applications, not for new development, and the :: syntax does not exist in modern Angular (2+), which uses OnPush change detection instead.
Prerequisites
- AngularJS 1.3 or later. Check in the browser console:
angular.version.fullshould report 1.3.x or higher. Earlier versions silently ignore the syntax or misparse it. - Identify which bindings are truly immutable after first render — identifiers, timestamps, labels, loaded configuration. Anything the user edits or that updates live must keep a normal binding.
- A way to count watchers for verification (see the checks section below).
Applying one-time bindings
Per-expression bindings
Add :: directly after the opening of the interpolation or at the start of the expression in a directive:
<!-- Before: each cell keeps a live watcher forever -->
<tr ng-repeat="order in orders">
<td>{{order.id}}</td>
<td>{{order.customer}}</td>
<td>{{order.total | currency}}</td>
</tr>
<!-- After: watchers deregister once values are defined -->
<tr ng-repeat="order in orders">
<td>{{::order.id}}</td>
<td>{{::order.customer}}</td>
<td>{{::order.total | currency}}</td>
</tr>The same prefix works in ng-bind="::order.customer", ng-if="::order.isArchived", and similar expression-based directives.
Binding the collection itself — with a caveat
You can also freeze the repeated collection: ng-repeat="order in ::orders". This removes the watcher on the array reference, but it means later push or splice operations on orders will never render. Only use this form when the list is loaded once and never mutated. If rows are added or removed but the content of each row is fixed, keep a normal binding on the collection and use per-item one-time bindings inside the row instead.
The undefined-value trap
A one-time binding keeps its watcher until the expression produces a defined (non-undefined) value. If data arrives asynchronously, the binding stays live until the value resolves — which is usually what you want — but an expression that is undefined on first render will not immediately deregister. Do not expect watcher counts to drop before the data loads.
Verifying the improvement
Two checks confirm the change worked. First, count watchers before and after. A minimal scope-walker run in the browser console gives a rough total:
(function countWatchers() {
var root = angular.element(document).scope().$root;
var count = 0, queue = [root];
while (queue.length) {
var s = queue.pop();
if (s.$$watchers) count += s.$$watchers.length;
var child = s.$$childHead;
while (child) { queue.push(child); child = child.$$nextSibling; }
}
console.log('watchers:', count);
})();Run this in the browser's developer console on the page in question; no special permissions are needed, but it relies on debug data being enabled ($compileProvider.debugInfoEnabled, on by default). Compare the number with and without the :: bindings after the list has fully rendered.
Second, time a digest to confirm faster cycles:
var s = performance.now();
angular.element(document).scope().$root.$digest();
console.log('digest ms:', performance.now() - s);Trigger this after a typical user interaction rather than in isolation, and compare against a baseline taken before the change. Treat the numbers as indicative, not precise benchmarks.
Risks and recovery
The main risk is stale content: a one-time binding on a value that later changes will simply never update on screen, with no error. If a field stops updating after this change, remove the :: from that binding — recovery is a one-character-per-binding revert with no state to roll back. As a rule, audit each binding against the question "does this value ever change after it first appears?" and only convert the ones where the answer is no.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.