Cutting Digest Time in AngularJS 1.x with One-Time Binding
AngularJS digest cost scales with watcher count. One-time binding (::) in 1.3+ lets static bindings deregister after first render — here's a worked example, the async trap, and how to measure the win.
11 Mar 2026, 08:50 UTC

Your AngularJS page has a 500-row order history table, and every keystroke anywhere on the page takes a noticeable beat to respond. The culprit is usually not the DOM — it's the digest cycle walking thousands of watchers. If most of those bindings render data that never changes after it first appears, AngularJS 1.3's one-time binding syntax (::) is the cheapest fix available: it lets a watcher deregister itself once it has a stable value.
Why watcher count is the real bottleneck
AngularJS's change detection is a loop: on every digest, the framework re-evaluates every registered watcher expression and compares it to the previous value. A row with five interpolated fields contributes five watchers; 500 rows contribute roughly 2,500. Any event that triggers a digest — a click, an ng-model keystroke, an $http response — pays that cost, even if the table itself is static.
This is why "the page feels sluggish" on large AngularJS apps is so often a watcher-count problem rather than a rendering problem. Reducing watchers shortens every digest everywhere on the page.
What :: actually does
Prefixing an expression with :: marks it as one-time. The watcher still runs during digests, but as soon as the expression produces a value that is not undefined, the watcher removes itself. From then on, the displayed value is frozen — scope changes no longer reach the DOM for that binding.
The syntax works in interpolation ({{::row.total}}), in ng-bind="::row.total", and in expressions passed to many directives. It does not apply to ng-model, which is inherently two-way; user input ignores one-time semantics.
Worked example: a read-only order table
Assume a controller loads completed orders — historical data that is never edited in place:
<table>
<tr ng-repeat="row in ::orders track by row.id">
<td>{{::row.id}}</td>
<td>{{::row.customer}}</td>
<td>{{::row.total | currency}}</td>
</tr>
</table>Before the change, this table holds about 1,500 watchers (500 rows × 3 bindings, plus the repeat's own watch on the collection). After adding ::, the per-row watchers deregister after the first digest with data, and the collection watch on orders also goes away once the array exists. The rest of the page — search box, live status panel — keeps normal bindings and stays reactive.
The async trap and the ng-if guard
One-time bindings stabilize on the first non-undefined value. If your template renders before $http data arrives, orders may be undefined initially — fine — but a partially initialized object can stabilize too early and freeze an empty or placeholder value. The standard guard is to delay rendering until data exists:
<section ng-if="ordersLoaded">
<!-- :: bindings here stabilize only after the flag flips -->
</section>Set ordersLoaded = true in the $http.then() callback after assigning orders. The ng-if creates the subtree only when data is present, so the one-time bindings see real values on their first digest.
Trade-offs and limitations
- Frozen forever. A
::value never updates after stabilization. Wrong for editable forms, live counters, or anything the user expects to change. To "refresh" a one-time section you must destroy and recreate it (e.g., toggling anng-if). - Frozen membership.
ng-repeat="x in ::items"freezes the collection too — items added later will not appear. This surprises people; if the list grows, keep the collection watched and use::only on the per-item fields. - Version requirement.
::was introduced in AngularJS 1.3.0. On 1.2.x it is a syntax error or silently misbehaves. Checkangular.version.fullin the browser console before relying on it. - Not a universal fix. If your digest is slow because of expensive watcher expressions or filters, removing watchers from an already-cheap table won't help much. Measure first.
- End-of-life context. AngularJS 1.x left long-term support at the end of 2021. One-time binding is a legitimate maintenance technique for existing apps, but new projects should not adopt the framework.
Verify the win, don't assume it
Count watchers before and after. A quick approach in the browser console is to walk the scope tree and sum $$watchers lengths, or use a watcher-counting browser extension. Then compare: load the page, trigger a typical interaction, and record digest duration via devtools profiling or by wrapping $rootScope.$digest with timestamps in a debug build. You should see both watcher count and per-digest time drop roughly in proportion to the bindings you converted.
For correctness, a small unit test pays off: render a template containing {{::value}}, run a digest, change value on the scope, digest again, and assert the DOM text did not change. Add a second test for the async case — render with the ng-if guard false, flip it after assigning data, and confirm the real values appear.
Closing
Pick your largest, most static list — reports, history tables, audit logs — and convert its bindings to :: behind an ng-if loaded flag. Measure watchers before and after. If the digest time drops, you've found a pattern you can roll out across the app in an afternoon; if it doesn't, you've learned the bottleneck is elsewhere, which is equally useful information.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.