Angular still checks your application the way it always did. It just stopped being told to.

Angular checks views, not components, and it only checks the ones something has marked. Since version 22, components are OnPush by default, applications have been zoneless by default since version 21, and the old Default strategy is now a deprecated alias for a new one called Eager. That means the model most of us learned, where Zone.js notices an async callback and Angular walks the whole tree looking for changes, is now the opt-in exception rather than the rule.

I have been upgrading the same large Angular application one version at a time since around v16, when the first of these pieces started landing. We are on v21 now. Watching this arrive gradually, rather than reading about it all at once, changed how I think about change detection, because each release moved one more decision out of the framework’s hands and into mine.

This post is my attempt to write down the model I ended up with. Not the version I learned from tutorials years ago, but the one that matches what the source actually does today. Let’s dig into it.

1. Why most change detection explanations are now out of date

Angular v22 shipped on 3 June 2026 with a breaking change that is easy to skim past in a changelog:

Component with undefined changeDetection property are now OnPush by default. Specify changeDetection: ChangeDetectionStrategy.Eager to keep the previous behavior.

If you look at the enum itself, the shift is clearer than any release note can make it.

Listing 01packages/core/src/change_detection/constants.ts at v22.1.3TypeScript
export enum ChangeDetectionStrategy {
  OnPush = 0,   // NOTE: OnPush is enabled by default.
  Eager = 1,
  Default = 1,  // @deprecated Use `Eager` instead.
}

OnPush is zero. In an enum, zero is the value you get when nobody specifies anything, so OnPush is not merely recommended now, it is what the absence of a decision means. And the strategy that used to be called Default has been renamed to Eager and deprecated, which is about as direct as a framework can be about which one it thinks you should be using.

The naming matters more than it looks. Default described a position in the API. Eager describes a behaviour: check this component whenever the traversal reaches it, whether or not there is any reason to believe it changed. Once you read it that way, it becomes hard to argue for.

2. What Angular is actually checking

Almost every explanation of change detection, including ones I have given people at work, says Angular walks the component tree. That is a useful simplification and it survives right up until the moment you try to debug something, at which point it quietly stops being true.

Angular checks views. Internally a view is an LView, which is an array holding all the runtime state for one instance of a template: its bindings, its child views, its flags, and a reference to the shared TView that describes the template’s static structure. A component has a view, but so does every @if block and every iteration of an @for block. Those are embedded views, and they are checked and skipped independently of the component that declares them.

This distinction matters for a practical reason. Every flag I am about to describe lives on the view, not on the component class. When you are staring at a component that will not update, the question is never really “why did Angular skip my component”. It is “which view was not marked, and what was supposed to mark it”.

3. What Zone.js was really doing, and what replaced it

Zone.js worked by monkey-patching the browser. It replaced setTimeout, Promise, addEventListener, XMLHttpRequest and a long list of other asynchronous APIs with wrapped versions that told Angular when something had finished. When the last of those tasks settled, Angular ran ApplicationRef.tick() and change detection began at the root.

Here is the part worth sitting with. Zone.js never knew what had changed. It only knew that something asynchronous had happened. It was a scheduling mechanism wearing a change detection costume. Angular’s response to that signal was to start at the root of the application and check everything that was checkable, comparing every binding against its previous value, because it had no better information available.

That is why the old model felt so forgiving. You could assign to any property from any callback and the view updated, not because Angular understood your assignment, but because it re-checked the entire tree shortly afterwards on the off chance.

Consider an ordinary component of the kind that exists in every codebase that has been around for a few years.

Listing 02A component that relies on Zone.js without knowing itTypeScript
@Component({
  selector: 'app-order-status',
  template: `<p>{{ status }}</p>`,
})
export class OrderStatusComponent implements OnInit {
  status = 'Checking...';

  ngOnInit() {
    setTimeout(() => {
      this.status = 'Ready for pickup';
    }, 1000);
  }
}

Under Zone.js, with the strategy formerly known as Default, this works. The patched setTimeout fires, Zone.js reports that the microtask queue has drained, Angular ticks from the root, reaches this view, re-evaluates {{ status }}, notices the string differs from last time, and updates the DOM. Nothing in the component participates in that process. It just benefits from it.

Remove Zone.js and nothing happens. The timeout still fires and this.status still becomes 'Ready for pickup'. The DOM keeps showing Checking... indefinitely, because no view was ever marked and no traversal was ever scheduled. Same code, different outcome, and the difference is not in your component at all.

4. OnPush by default, and what Eager is for

So what does the strategy actually do? It decides which flag a view starts life with.

Listing 03packages/core/src/render3/view/construction.ts at v22.1.3TypeScript
export function getInitialLViewFlagsFromDef(def: ComponentDef<unknown>): LViewFlags {
  let flags = LViewFlags.CheckAlways;
  if (def.signals) {
    flags = LViewFlags.SignalView;
  } else if (def.onPush) {
    flags = LViewFlags.Dirty;
  }
  return flags;
}

An Eager component gets CheckAlways, which means exactly what it says. An OnPush component gets Dirty instead, which is why it renders correctly the first time and then goes quiet: it begins dirty, gets checked once, has the flag cleared, and from then on needs a reason to be checked again.

That is the whole strategy. It is not a different algorithm, it is a different starting flag. Everything else follows from how the traversal treats those flags.

There is a wrinkle here worth knowing before you upgrade. If you take an existing application to v22, you do not silently receive the new default. Angular runs a migration called change-detection-eager which writes ChangeDetectionStrategy.Eager onto every component in the codebase, so your behaviour is preserved and nothing breaks. The new default applies to components you write from then on. Moving an existing codebase across is deliberate work, done a component at a time, and I have written about what that involves in migrating a large Angular app to zoneless.

When is Eager still legitimate? Genuinely rarely. The honest cases I have run into are a component wrapping a third-party library that mutates its own inputs from outside Angular’s knowledge, and a piece of legacy code where the cost of working out what should mark it is real and the component is cheap to check. Both are compromises, and both are worth a comment explaining why.

The failure mode I would watch for is reaching for Eager because a component stopped updating after an upgrade. That component did not need Eager. It needed something to mark it, and switching strategies hides the missing mark instead of fixing it. You will meet the same bug again later in a component where Eager is not an option.

5. How a view gets marked for checking

The flags themselves are an ordinary bit field.

Listing 04packages/core/src/render3/interfaces/view.ts at v22.1.3TypeScript
export const enum LViewFlags {
  CreationMode = 1 << 2,
  FirstLViewPass = 1 << 3,
  CheckAlways = 1 << 4,
  Dirty = 1 << 6,
  Attached = 1 << 7,
  Destroyed = 1 << 8,
  IsRoot = 1 << 9,
  RefreshView = 1 << 10,
  HasEmbeddedViewInjector = 1 << 11,
  SignalView = 1 << 12,
  HasChildViewsToRefresh = 1 << 13,
}

Four of these do the work I care about here. CheckAlways means check this view on every global pass. Dirty means this view has been marked and should be checked on the next global pass. RefreshView means refresh this view regardless of which mode the traversal is running in. And HasChildViewsToRefresh means this view itself has nothing to do, but something underneath it does.

That last one is the interesting flag, and it is the one that makes local updates possible. Hold onto it.

Now, what does markForCheck() do? It calls markViewDirty, and markViewDirty walks upward.

Listing 05packages/core/src/render3/instructions/mark_view_dirty.ts at v22.1.3TypeScript
export function markViewDirty(lView: LView, source: NotificationSource): LView | null {
  const dirtyBitsToUse = isRefreshingViews()
    ? LViewFlags.Dirty
    : LViewFlags.RefreshView | LViewFlags.Dirty;
  lView[ENVIRONMENT].changeDetectionScheduler?.notify(source);
  while (lView) {
    lView[FLAGS] |= dirtyBitsToUse;
    const parent = getLViewParent(lView);
    if (isRootView(lView) && !parent) {
      return lView;
    }
    lView = parent!;
  }
  return null;
}

Read the loop carefully. It sets the dirty bits on the view you called it from, then on that view’s parent, then on that parent’s parent, all the way to the root, without stopping. Every ancestor of your component is now dirty, and every one of them will be checked on the next pass.

This is why I have come to think of markForCheck() as a blunt instrument. It works, and for years it was the only tool available, but it says “check me” by making the entire path above you dirty as well. Your component might be a leaf in a deeply nested layout, and you have just volunteered every one of its ancestors for a full binding check.

6. What signals changed about marking

Signals do something different, and the difference is the most interesting thing in this whole area.

Listing 06packages/core/src/render3/util/view_utils.ts at v22.1.3TypeScript
export function markViewForRefresh(lView: LView) {
  if (lView[FLAGS] & LViewFlags.RefreshView) {
    return;
  }
  lView[FLAGS] |= LViewFlags.RefreshView;
  if (viewAttachedToChangeDetector(lView)) {
    markAncestorsForTraversal(lView);
  }
}

export function markAncestorsForTraversal(lView: LView) {
  lView[ENVIRONMENT].changeDetectionScheduler?.notify(
    NotificationSource.MarkAncestorsForTraversal,
  );
  let parent = getLViewParent(lView);
  while (parent !== null) {
    if (parent[FLAGS] & LViewFlags.HasChildViewsToRefresh) {
      break;
    }
    parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
    if (!viewAttachedToChangeDetector(parent)) {
      break;
    }
    parent = getLViewParent(parent);
  }
}

When a signal read in a template changes, the view that read it gets RefreshView. Its ancestors get something else entirely: HasChildViewsToRefresh. They are not marked dirty. They are marked as containing something that needs work.

The difference in one sentence: markForCheck() tells every ancestor “you need checking”, while a signal tells every ancestor “something below you needs checking, please let the traversal through”.

There is a second detail in that loop that I like a lot. The walk upward stops as soon as it meets a parent that already carries the flag, on the reasoning that if this parent is already marked then everything above it must be too. In an application where several signals change before the next pass, the marking work collapses instead of repeatedly walking to the root.

This is what people mean by local change detection, and it is worth being precise about it. The update is local not because Angular skips straight to your component, since it still enters from the top, but because the components on the way down are traversed rather than checked. Their bindings are never re-evaluated. They are just doors the traversal walks through.

7. How the traversal actually runs

All of this comes together in one function, and reading it was the moment the whole model finally clicked for me.

Listing 07detectChangesInView, packages/core/src/render3/instructions/change_detection.ts at v22.1.3TypeScript
// Refresh CheckAlways views in Global mode.
let shouldRefreshView: boolean = !!(
  mode === ChangeDetectionMode.Global && flags & LViewFlags.CheckAlways
);

// Refresh Dirty views in Global mode.
shouldRefreshView ||= !!(
  flags & LViewFlags.Dirty && mode === ChangeDetectionMode.Global && !isInCheckNoChangesPass
);

// Always refresh views marked for refresh, regardless of mode.
shouldRefreshView ||= !!(flags & LViewFlags.RefreshView);

// Refresh views when they have a dirty reactive consumer, regardless of mode.
shouldRefreshView ||= !!(consumer?.dirty && consumerPollProducersForChange(consumer));

lView[FLAGS] &= ~(LViewFlags.HasChildViewsToRefresh | LViewFlags.RefreshView);

if (shouldRefreshView) {
  refreshView(tView, lView, tView.template, lView[CONTEXT]);
} else if (flags & LViewFlags.HasChildViewsToRefresh) {
  detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Targeted);
  const components = tView.components;
  if (components !== null) {
    detectChangesInChildComponents(lView, components, ChangeDetectionMode.Targeted);
  }
}

There are two modes. In Global mode, Dirty and CheckAlways views are refreshed along with anything flagged RefreshView. In Targeted mode, only views with RefreshView or an updated signal are refreshed. A global pass is what you get from a full tick; targeted is what the traversal switches to once it is threading its way down toward a specific view.

The else if is the mechanism I spent years not understanding. If a view should not be refreshed, but it carries HasChildViewsToRefresh, Angular does not check it and does not stop either. It descends into its children in Targeted mode. The view’s own template function never runs, none of its bindings are compared, and yet the traversal still reaches whatever is underneath.

Notice too that the flags are cleared before the refresh, not after. That is deliberate: it allows a view to be re-dirtied during its own refresh, which is what makes patterns like an effect marking something during a pass behave sanely instead of being silently discarded.

8. What happens to your existing async code

If you are maintaining an application that predates all of this, the practical question is what breaks and what the smallest correct fix is. Three patterns cover most of it.

A timer or promise assigning to a field. The OrderStatusComponent from earlier is this case. The callback runs, the field changes, and nothing tells the view. The smallest fix is to make the field a signal, because then reading it in the template registers the dependency and writing to it marks the view automatically.

Listing 08The same component, fixedTypeScript
@Component({
  selector: 'app-order-status',
  template: `<p>{{ status() }}</p>`,
})
export class OrderStatusComponent implements OnInit {
  status = signal('Checking...');

  ngOnInit() {
    setTimeout(() => {
      this.status.set('Ready for pickup');
    }, 1000);
  }
}

An HTTP subscription assigning to a property. Same shape, same problem. Either convert the stream with toSignal() from @angular/core/rxjs-interop, or let the template subscribe through the async pipe, which marks the view for you. Reach for markForCheck() here only if neither is practical, and remember what section 5 showed you about what it costs.

A third-party library calling back from outside Angular. A charting library, a map, a drag-and-drop handler, or anything holding its own event listeners. These are the awkward ones, because the callback is not yours to convert. Wrap the boundary: have the callback write to a signal that your template reads, so the mutation enters Angular’s reactive graph at a point you control rather than at a point the library chose.

The unifying idea is that every one of these bugs is the same bug. Something changed and nothing marked a view. Once you start reading them that way, the fix is usually obvious within a few seconds, and it is nearly always about establishing a dependency rather than forcing a check.

I am deliberately keeping this at the level of a single component. Rolling this out across a large codebase is a different problem with its own ordering, its own third-party compatibility work and its own effect on your test suite, and it deserves a separate post rather than a paragraph here.

9. Why your view did not update

Here is the checklist I actually run through, in order, when something will not update.

  1. Is the value read in the template? A signal that nothing reads creates no dependency. This catches more cases than I would like to admit.
  2. Did you mutate instead of replacing? Pushing into an array held by a signal does not notify anyone. set or update with a new reference does.
  3. Is the view attached? Both markViewForRefresh and markAncestorsForTraversal check whether the view is attached to a change detector and stop if it is not. A detached view will not be reached no matter what marks it.
  4. Is the mutation happening outside Angular entirely? A third-party callback or a raw addEventListener registered outside the framework will not schedule anything on its own.
  5. Only now, consider the strategy. If the first four are fine and it still does not update, you have found something interesting. If you have not checked them, changing the strategy is just hiding the problem.

10. A short recap

Angular checks views rather than components, and every decision it makes is driven by flags on those views. The strategy you choose only determines the flag a view is born with: CheckAlways for Eager, Dirty for OnPush. From v22 the second of those is what you get by default, and Default itself is now a deprecated alias for Eager.

Zone.js was never a change detection mechanism. It was a scheduler that told Angular something had happened without saying what, and the framework compensated by checking everything it could. Removing it does not make Angular less capable. It removes the guessing, and it means the framework now needs you to say what changed.

markForCheck() says it by dirtying every ancestor up to the root. Signals say it far more precisely, by flagging the reading view for refresh and asking its ancestors only to allow the traversal through. That distinction, between a view being checked and a view being passed through, is the whole of local change detection, and it is visible in about thirty lines of detectChangesInView.

What I find genuinely satisfying about this is that the mental model got smaller as the framework got more capable. For years the answer to “why did this update” was “because Zone.js noticed something and Angular checked everything”. Now it is “because something marked this view”. That is a much better sentence to be able to say about your own application.

Sources and further reading

If you have read what React Fiber actually changed, some of this will feel familiar. Both frameworks spent years solving the same underlying problem, which is knowing what work can be skipped, and both ended up building machinery to avoid doing work rather than to do it faster.