---
title: "NG0100 ExpressionChangedAfterItHasBeenCheckedError: Why It Surfaces in Zoneless Angular"
description: "Why NG0100 ExpressionChangedAfterItHasBeenCheckedError surfaces after a move to zoneless or Angular 22, what it now means (a binding updated without notifying Angular), the test trap with fixture.detectChanges(), and the fixes by where the change came from."
deck: "`NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked` is the dev-mode error every Angular developer has hit. After a move to [zoneless](https://angular.dev/guide/zoneless) or Angular 22's OnPush default, it shows up in code and tests that never threw it. The cause shifted: in zoneless it usually means a binding changed without notifying Angular, which is the contract zoneless enforces, not a lifecycle-timing quirk you move to `ngOnInit`."
author: "André Ramos"
url: "https://andreramos.dev/angular/expressionchanged-error-zoneless-angular/"
lang: "en"
type: "article"
---

# NG0100 ExpressionChangedAfterItHasBeenCheckedError: Why It Surfaces in Zoneless Angular

> `NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked` is the dev-mode error every Angular developer has hit. After a move to [zoneless](https://angular.dev/guide/zoneless) or Angular 22's OnPush default, it shows up in code and tests that never threw it. The cause shifted: in zoneless it usually means a binding changed without notifying Angular, which is the contract zoneless enforces, not a lifecycle-timing quirk you move to `ngOnInit`.

## The error, verbatim

The message reads `NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'X'. Current value: 'Y'.` It fires only in development. After each change detection run, Angular does a second pass to verify nothing changed, and if a binding now reads differently from what it just set, it throws. The check exists to catch a view left in an inconsistent state.

The classic causes are old news: a getter or method that returns a new value each call, a child component writing to a parent binding, a value set in `ngAfterViewInit` after the parent was already checked. If that were your case, the fix would be the textbook one, move the write to `ngOnInit` or `ngAfterContentInit`, and you would not be reading this. What changed is where the error now appears.

## Why it is showing up now

Teams report a wave of NG0100 right after upgrading to Angular 21, including in unit tests that were green the day before, and Angular 22 widens the blast radius by [making OnPush the default](/angular/onpush-default-angular-22) change detection. If you did not touch the component but the error appeared, the change detection regime underneath it did.

Zoneless and OnPush are stricter than the Zone.js default. With Zone, a broad change detection pass refreshed bindings whether or not anything explicitly asked it to. Without Zone, a binding refreshes only when something notifies Angular: a signal write, an input change, the async pipe, or `markForCheck`. The dev-mode verification pass now also catches the bindings the scheduler would not have refreshed, which is exactly the code that quietly relied on Zone before.

## What NG0100 means in zoneless

In a Zone.js app, NG0100 was usually a timing quirk: two passes, different values, fix the lifecycle hook. In zoneless it carries more information. Angular throws it when a binding was updated without a notification, no signal write, no `markForCheck`, no async pipe. That is the same class of bug that surfaces when [an app goes zoneless](/angular/code-that-breaks-when-angular-goes-zoneless): the value changed, but nothing told Angular to schedule a refresh.

So read NG0100 in zoneless as an early warning, not an annoyance. In development it throws; in production, with the dev-mode check gone, the same binding would simply render stale and you would chase a ghost. The error is doing you a favor by naming the binding before it ships.

| Where the change comes from | Why zoneless throws NG0100 | Fix |
|---|---|---|
| A timer or callback mutates a field | The field changed, nothing scheduled a refresh | Hold it in a signal, or call markForCheck |
| A getter returns a new value each call | The two passes read different values | Memoize it, or move it to a computed signal |
| A child writes to a parent input in a hook | The parent was already checked this pass | Write earlier, or model it as a signal the parent reads |
| A reactive form status drives the view | statusChanges emitted, the template read was not scheduled | Bridge statusChanges into a signal |

## The test trap: fixture.detectChanges()

The most common new place to hit NG0100 is a test. In zoneless, calling `fixture.detectChanges()` after a mutation throws it, because `TestBed` enforces that the component is OnPush-compatible and flags a binding that was updated without a notification. The habit of calling `detectChanges()` after every change is now the trigger, not the fix, and it was hiding the missing notification all along.

Stop forcing change detection. Drive the component the way production does, write through a real channel and await stability, so the test exercises the same notification path the app relies on. A test that passes only because you called `detectChanges()` is a test that would let a stale-view bug through.

*Zoneless test: drive notifications, do not force detection*

```ts
// Before: forces CD, throws NG0100 in zoneless, hides the missing notification
component.label = 'updated';
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('updated');

// After: write through the real channel (a signal), then await stability
component.label.set('updated');
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('updated');
```

## The fix is rarely to silence it

The wrong move is to scatter `ChangeDetectorRef.detectChanges()` or wrap the assignment in `setTimeout` until the error goes quiet. That hides the inconsistency the check found, and in zoneless it hides a view that will render stale in production. Silencing NG0100 trades a loud dev-mode error for a silent production one.

The right fix names where the value changed and routes it through something Angular watches. A field a timer mutates becomes a signal. A getter that recomputes becomes a `computed`. A third-party callback updating the UI gets a `markForCheck` at the edge, or an adapter that writes a signal. The classic lifecycle-hook fixes still apply when the cause is genuinely timing; the zoneless cases need a notification, not a second `detectChanges`.

*Production fix: a notification, not a silenced error*

```ts
// Throws NG0100 in zoneless: the field changes, nothing notifies Angular
ngOnInit() {
  setInterval(() => { this.elapsed = this.elapsed + 1; }, 1000);
}

// Fixed: a signal is a notification; the template refresh is scheduled
readonly elapsed = signal(0);
ngOnInit() {
  setInterval(() => this.elapsed.update((n) => n + 1), 1000);
}
```

## Reusable artifact: Debugging NG0100 in zoneless Angular

- Read the Previous and Current values in the message; they name the binding that changed.
- Ask what updated that binding, and whether anything notified Angular (signal, markForCheck, async pipe).
- Timer, callback, or third-party mutation of a field: move it into a signal, or markForCheck at the edge.
- A getter that returns a new value each call: memoize it or use a computed signal.
- In tests, stop calling fixture.detectChanges() after mutations; write through real channels and await stability.
- Do not silence it with detectChanges or setTimeout; in zoneless that hides a view that goes stale in production.

## FAQ

### What is NG0100 ExpressionChangedAfterItHasBeenCheckedError?

A development-only error Angular throws when a template binding changes value after change detection has already run. Angular does a second verification pass in dev mode; if a binding now reads differently than it just set, it throws to flag an inconsistent view.

### Why did NG0100 start after I upgraded to Angular 21 or 22?

Zoneless (the default for new projects since v21) and [OnPush as the default in v22](/angular/onpush-default-angular-22) are stricter than the Zone.js default. A binding that Zone used to refresh anyway now refreshes only when something notifies Angular, and the dev-mode check catches the ones that were silently relying on Zone.

### Why does fixture.detectChanges() throw NG0100 in zoneless tests?

In zoneless, `TestBed` enforces that your component is OnPush-compatible. Calling `fixture.detectChanges()` after a mutation flags a binding updated without a notification. Write through real channels (a signal, an event) and await stability instead of forcing detection.

### Is NG0100 a production problem?

The error itself is development-only. But in zoneless it usually points at a binding that changed without notifying Angular, which in production would render stale with no error at all. Fixing it removes a real bug, not just a warning.

## Sources checked

- https://angular.dev/errors/NG0100
- https://angular.dev/guide/zoneless
- https://angular.dev/guide/testing
- https://angular.dev/guide/components/lifecycle
- https://github.com/angular/angular/issues/59082
- https://github.com/angular/angular-cli/issues/32047
- https://blog.angular.dev/announcing-angular-v21-57946c34f14b

## Related

- [The Code That Breaks When Angular Goes Zoneless](https://andreramos.dev/angular/code-that-breaks-when-angular-goes-zoneless/)
- [OnPush Is the Default in Angular 22: What Breaks and What to Do](https://andreramos.dev/angular/onpush-default-angular-22/)
- [A Small Checklist Before Trying Zoneless](https://andreramos.dev/angular/zoneless-checklist/)
