How does AngularJS's digest cycle differ from Angular's change detection, and why did the difference matter for performance?
answer
- watchers versus views
- loop until stable versus one pass
- two-way cascades
- dev-mode check error instead of looping
basics
~20 sAngularJS's digest re-runs every watcher and repeats until no value changes. Angular checks its component tree once, top-down, with one-way data flow, and in development mode throws an error instead of looping when a checked value changes.
solid answer
~40 sIn AngularJS every binding registers a **watcher** on a `$scope`. A **digest** runs all watchers, compares each value with the last one (dirty checking), runs listeners for changes, and **repeats the whole pass until nothing changes**, giving up after a fixed number of iterations. Changes from outside AngularJS need `$scope.$apply()` to start a digest. Two-way bindings let a child change a parent's value mid-digest, so cost grows with watchers times passes. Angular instead **checks the component tree once, top-down**; data flows parent to child, so one pass is enough. In development mode Angular runs a second verification pass and throws `ExpressionChangedAfterItHasBeenCheckedError` (NG0100) if a value changed, rather than looping. Components can also be skipped: `OnPush`, the default since v22, only checks a component when something marks it dirty.
go deeper
Remember the contrast: AngularJS dirty-checks watchers in a repeating digest; Angular checks its component tree once per cycle.
Explain why one-way data flow makes one pass enough, what NG0100 signals, and why digests cost more as bindings grow.
Relate the two models to a hybrid app: UpgradeModule couples them and runs extra cycles, downgradeModule decouples them.
Use the models to judge whether a slow AngularJS screen should be optimised in place or migrated first.
Both frameworks answer the same question, "when data changes, which parts of the page must update?", but with different models. The difference explains a lot of AngularJS's performance reputation and several Angular rules. ## AngularJS: watchers and the digest loop 1. Every binding in a template (`{{ user.name }}`, `ng-if`, `ng-repeat`) registers a **watcher** on a `$scope`: a function that returns a value plus a listener to run when it changes. 2. A **digest** started from `$rootScope`, which is what `$apply()` does, walks the scope tree and evaluates **every watcher**, comparing the new value with the previous one. This is **dirty checking**. 3. If any listener changed something, the digest runs **another full pass**, because that change might affect watchers already evaluated. 4. It repeats until a pass finds no changes, or it hits an iteration limit and throws, which usually means two watchers keep changing each other. Digests are started by AngularJS itself for its own events (`ng-click`, `$http`, `$timeout`). Anything from outside, such as a third-party library callback, needs **`$scope.$apply()`** or the view stays stale. ### Why it got slow - **Cost scales with every binding on the page**, not with what changed: a digest evaluates all watchers. - **Two-way bindings** let a child change parent data during a digest, forcing extra passes. - **Large `ng-repeat` lists** multiply watchers per row, per field. ## Angular: a single top-down pass 1. The component tree is **checked from the root down**. For each view, Angular evaluates its template bindings and updates the DOM where values changed. 2. **Data flows one way**: parents pass inputs to children, children emit events up. A child cannot change what its parent already rendered during the same pass, so **one pass is enough**. 3. In **development mode**, Angular runs a verification check afterwards. If a binding's value changed, it throws **`ExpressionChangedAfterItHasBeenCheckedError`** (NG0100) instead of running another pass. The error points at a data-flow bug; production mode does not throw it. 4. Components can be **skipped**. With `OnPush`, the default for components since v22, a component is only checked when it is marked dirty, for example by a signal it reads, an input change or an event in it. ## Side by side | | AngularJS digest | Angular change detection | | :-- | :-- | :-- | | Unit checked | Every watcher on every scope | Every view in the tree, minus skipped subtrees | | Passes per cycle | Repeats until stable (bounded) | One pass; dev mode adds a verification check | | Data flow | Two-way by default | One-way by default | | Starting a cycle from outside | `$scope.$apply()` | Scheduled by Angular (zone.js in zone-based apps, notifications in zoneless apps) | | Unstable data | Iteration limit error | NG0100 in development | ## What triggers a cycle - **AngularJS**: its own directives and services call `$apply` for you; everything else must do it explicitly. - **Angular**: apps using zone.js get a check after async work in the Angular zone; since v21, apps are **zoneless** by default and Angular schedules checks from notifications: `markForCheck()` (which the `async` pipe calls), `ComponentRef.setInput`, updating a signal read in a template, and bound template or host listeners. The details of zone-based scheduling and of `OnPush` belong to the change-detection topics; the point for this comparison is that Angular decides when and how far to check, rather than looping until the page stops moving. ## Why it matters in a hybrid app With `@angular/upgrade`'s `UpgradeModule`, both systems run at once. It triggers an AngularJS `$digest` whenever Angular's change detection settles, so AngularJS bindings stay current without `$apply` calls. The price is **more change-detection runs than strictly needed**, which is why `downgradeModule()` exists as a looser, faster alternative. ## Common misconceptions - Angular does **not** loop until stable; a changing value after the check is treated as a bug. - Angular does **not** re-evaluate "all watchers"; skipped `OnPush` subtrees are not visited at all. - AngularJS performance problems were mostly about **watcher count and repeated passes**, not about DOM updates themselves.
- What does NG0100 tell you in an Angular app, in AngularJS terms?ExpressionChangedAfterItHasBeenCheckedError means a binding's value changed after Angular checked it in this pass, usually because a child or a lifecycle hook updated data its parent already rendered. AngularJS would have run another digest pass; Angular refuses and reports it in development mode, because one-way data flow should make a second pass unnecessary.
- Why do large ng-repeat lists hurt AngularJS more than @for lists hurt Angular?Each row adds watchers for every binding, and every digest evaluates all of them, possibly several times. In Angular, a list inside an OnPush component is only visited when that component is marked dirty, and @for's track expression lets Angular reuse rows instead of rebuilding them.
AngularJS's digest is like re-reading a whole spreadsheet after every edit and starting over whenever a formula changes a cell. Angular reads the sheet once, top to bottom, where cells only depend on cells above; a cell that changes afterwards is flagged as an error, not recalculated.
saying these in an interview costs you the question
- Angular re-runs change detection until values stop changing, like a digest.
- Angular needs $apply-style calls whenever third-party code changes data.
- NG0100 errors also stop production builds from rendering.
- AngularJS digests only evaluate the watchers whose data changed.
- One-way data flow in Angular means inputs can never change.