AngularJS One-Time Bindings: Cutting Digest Watchers in Read-Only Lists
Large ng-repeat lists can create thousands of digest watchers. AngularJS one-time bindings remove watchers once values stabilize, but only for read-only data. Here is how to apply them safely and verify the impact.
03 Jul 2026, 12:56 UTC

The slow dashboard problem
You inherit an AngularJS dashboard with a 500-row ng-repeat. Each row shows an ID, a name, a formatted timestamp, and a status badge. Typing in a search box feels sticky. Scrolling lags. The culprit is usually not the DOM itself, but the digest cycle: every {{item.name}} in that list registers a watcher, and AngularJS re-evaluates all of them on every $digest. With five bindings per row, you are asking the framework to check 2,500 expressions every time anything changes.
What one-time binding actually does
AngularJS expressions support a :: prefix. When the expression first returns a defined value and then returns the same value on the next digest, AngularJS removes that watcher permanently. Later mutations to the underlying model are ignored. This is a deliberate trade: you give up live updates in exchange for a smaller watcher list.
The feature is most defensible for read-only presentation data: IDs, names, formatted timestamps, or rows fetched once and never edited. It is a poor fit for status flags, counters, form controls, or anything a directive or service mutates after render.
A worked example: read-only rows
Consider a list of audit-log entries. The data arrives once from $http and is never edited in this view.
<!-- app.html -->
<div ng-controller="LogCtrl as vm">
<div ng-repeat="entry in vm.entries track by entry.id">
<span class="id">{{::entry.id}}</span>
<span class="ts">{{::entry.formattedAt}}</span>
<span class="msg">{{::entry.message}}</span>
</div>
</div>
Two things happen here. First, ::entry.id, ::entry.formattedAt, and ::entry.message each register a watcher only until the value stabilizes. Second, track by entry.id tells AngularJS to reuse the existing DOM element and scope when the collection changes, rather than destroying and rebuilding rows. The performance win comes from fewer watchers plus fewer scope churns, not from :: alone.
When the data is not ready yet
If vm.entries is populated by a promise, the first digest may see undefined for every field. The watcher stays registered until the expression becomes defined. Once it stabilizes, it deregisters and never updates again. That means a permanently undefined binding will never recover, and a value that mutates after stabilization will leave stale text in the DOM.
Reproduce this before trusting the pattern: render {{::item.label}} as undefined, resolve it from a $timeout or promise, mutate it again, and confirm the DOM updates exactly once and then stops.
Verifying watcher counts without folklore
Do not guess whether the optimization worked. Add a small diagnostic directive that logs scope.$$watchers.length in link and again after a $timeout. Run this in your browser's developer console or as a temporary module in your app; it requires no special permissions beyond normal page script access.
// watcher-count.js (temporary diagnostic)
angular.module('diag', [])
.directive('watcherCount', function($timeout) {
return {
restrict: 'A',
link: function(scope, element) {
var label = element.attr('watcher-count') || 'scope';
console.log(label + ' initial watchers:', scope.$$watchers.length);
$timeout(function() {
console.log(label + ' post-digest watchers:', scope.$$watchers.length);
});
}
};
});
Attach watcher-count="row" to a repeated element and compare the same list rendered with normal bindings versus :: bindings. Note that scope.$$watchers is private API and may change across 1.x releases, so treat it as a debugging aid, not a production contract.
For a second check, use Chrome DevTools Performance profiling while forcing repeated digests—an interval or a button calling $scope.$apply()—and compare scripting time before and after the change. Digest cost also depends on filters, directives, custom $watch usage, and DOM size, so a smaller watcher count does not guarantee a faster app.
Limits and version reality
One-time binding cannot be used where AngularJS needs a writable model, such as ng-model. For editable data, use normal binding, controllerAs, or explicit $scope.$apply/$onChanges patterns in components.
AngularJS 1.8.x is the final 1.x line and the project is long past end-of-life. Before shipping changes, check angular.version.full in your console and confirm the current security posture against official Angular channels.
| Pattern | Watcher behavior | Safe for |
|---|---|---|
{{item.name}} | Stays active forever | Mutable state, forms |
{{::item.name}} | Removed after stable value | Read-only IDs, timestamps |
{{::item.status}} | Removed after stable value | Risky if status mutates |
Actionable takeaway
Apply :: only to fields that are read-only for the lifetime of the view, pair it with track by in large lists, and verify the effect with a temporary watcher-count directive and a DevTools recording. If a value might arrive late or change after render, leave it as a normal binding—silent staleness is harder to debug than a slow digest.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.