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 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 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: 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 fromWhy zoneless throws NG0100Fix
A timer or callback mutates a fieldThe field changed, nothing scheduled a refreshHold it in a signal, or call markForCheck
A getter returns a new value each callThe two passes read different valuesMemoize it, or move it to a computed signal
A child writes to a parent input in a hookThe parent was already checked this passWrite earlier, or model it as a signal the parent reads
A reactive form status drives the viewstatusChanges emitted, the template read was not scheduledBridge 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 detectionts
// 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 errorts
// 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 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

Modern Angular Playbook

This article is one play.

The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.

You get both PDFs by email, through the Dojo IA list.

Open playbook →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 10 min

The Code That Breaks When Angular Goes Zoneless

A code-heavy review guide for timers, subscriptions, forms, third-party callbacks, and tests before a zoneless Angular spike.

Read article →

Guide · 8 min

OnPush Is the Default in Angular 22: What Breaks and What to Do

Why Angular 22 makes OnPush the default, what actually breaks, the two ways to fix it, and how the migration's Eager additions double as your cleanup backlog.

Read article →

Note · 5 min

A Small Checklist Before Trying Zoneless

A readiness checklist for testing zoneless in an existing Angular app without confusing a compatibility spike with a rollout plan.

Read article →