The Rendering Cycle
Let's examine how the different layers of a typical Angular web application collaborate, starting with a broad perspective before zooming into the specifics.
As noted in the overview, the application state consists of the data that dictates what appears on screen. This data drives DOM updates, which the browser then uses to draw text, images, buttons, and other visual components.
import { Component } from "@angular/core";
@Component({
selector: "app-root",
template: `
<div class="container">
<div>
<button (click)="fetchData()">Fetch data</button>
{{ status }}
<div>title: {{ title }}</div>
</div>
</div>
`,
styleUrls: ["./app.component.css"],
})
export class AppComponent {}
Application state mutations are typically triggered by asynchronous events stemming from user interactions.
To detect when the state might be due for a change, Angular relies on knowing when these events take place. This is where the zone.js library becomes relevant. It patches the browser's APIs, allowing interception of all asynchronous operations. Angular subscribes to the hooks provided by zone.js, using signals from DOM events, timers, AJAX/XHR calls, Promises, and similar sources as triggers to initiate change detection.
The underlying assumption is that most events result in state changes that must be reflected in the DOM and, consequently, on the screen.
This mechanism enables change detection to fire automatically after every asynchronous event. As soon as an event handler completes, Angular triggers its change detection process. Rather than communicating with zone.js directly, Angular uses NgZone, a wrapper around zone.js that filters which events Angular cares about (learn more in the chapter dedicated to zone.js).
So how does Angular tap into zones?
Whenever a relevant event occurs within the Angular zone (NgZone), its attached handler executes. In practice, this handler is often a method exposed by a component. The business logic within it can modify any data it needs—whether that's the shared application model/state or the component's own view state. Once Angular receives a notification from NgZone indicating that all microtasks are complete, it kicks off its change detection algorithm.
In the default scenario—meaning no component opts into the onPush strategy—every component in the tree gets checked once, starting from the root and proceeding in a depth-first manner (see the "order of checks" here). In development mode, this check runs twice to verify the state remains stable (see the chapter on unidirectional data flow). Dirty checking is performed on every binding using the change detector objects. Lifecycle hooks, queries, and bindings are all handled as part of this process (more details in the chapter on operations).
In Angular, each component corresponds to a data structure known as LView. This is where the framework records the last-known values for all template bindings, such as {{service.a}}. During change detection, these values are compared via dirty checking to see if any side effects need to be invoked. There's a one-to-one mapping between a component instance and its LView.
Angular wraps an LView with the ChangeDetectorRef service, which offers change detection APIs. By injecting this service into a component's constructor, you can access it directly. Since each component gets its own change detector, Angular maintains a tree of change detectors mirroring the component tree. This change detection graph is a directed graph, adhering to unidirectional data flow, so cycles are impossible.
At this stage, control returns to the browser. The event has been handled, business logic has updated the state, and Angular has synced the DOM through change detection. It's now up to the browser to paint the updates and process any macrotasks queued up, such as network calls or timers.
Bringing the screen up to date involves a well-understood sequence of rendering and painting phases, broken into sub-steps:
- [Rendering] Style calculations. The browser determines which CSS rules apply to which elements by matching selectors like
.headlineor.nav > .nav__item. Once the rules are identified, they're applied, and the computed styles for each element are determined. - [Rendering] Layout. With the applicable rules known, the browser calculates each element's dimensions and position on the page. Since the web layout model means elements influence each other—for instance, the width of the
<body>affects its children's widths and so on throughout the tree—this step can be quite complex. - [Painting] Paint. This step fills in pixels, covering text, colors, images, borders, shadows, and every other visual facet of the elements. Drawing typically happens on multiple surfaces, which are often called layers.
- [Painting] Compositing. Since the page parts were drawn on potentially multiple layers, they must be arranged on the screen in the proper order to render correctly. This is especially critical for overlapping elements, where an error could cause one element to incorrectly sit atop another.
The browser doesn't always run every sub-stage of this pipeline each frame. If a change touches only a "paint-only" property—like a background image, text color, or shadow—that doesn't affect layout, the browser skips the layout step but still proceeds with painting. The most efficient pipeline is one that skips everything except compositing, which is often the case for animations or scroll events.
The browser's role will be examined in depth in the next module of this course, which focuses on performance in Angular applications. In the meantime, I strongly suggest reading this informative piece: The Anatomy of a Frame.
To summarize, there are four primary parts at play:
- browser: rendering (style, layout), painting (painting, compositing)
- zone.js: monkey-patching browser API, tasks lifecycle management and notifications
- angular: change detection, DOM updates
- application: business logic, updating application state
Now, let's walk through a concrete example to tie these ideas together.
An in-depth look
Consider this straightforward example:
import { Component } from "@angular/core";
@Component({
selector: "app-root",
template: `
<div class="container">
<div>
<button (click)="fetchData()">Fetch data</button>
{{ status }}
<div>title: {{ title }}</div>
</div>
</div>
`,
styleUrls: ["./app.component.css"],
})
export class AppComponent {
title = "learn-angular";
status = null;
fetchData() {
this.status = "Loading...";
const req = new XMLHttpRequest();
req.open("GET", "https://jsonplaceholder.typicode.com/todos/1", true);
req.onreadystatechange = () => {
if (req.readyState === XMLHttpRequest.DONE && req.status === 200) {
const todo = JSON.parse(req.responseText);
this.title = todo.title;
this.status = null;
}
};
req.send(null);
}
}
It behaves as demonstrated here:

I opted for XHR instead of fetch to keep Profiler logs cleaner.
Since fetch relies on Promises, using it would introduce extra noise in the logs from microtask scheduling and handling.
Here is the complete sequence of events:
- the browser catches the click and places an event handler into the event queue (browser)
- zonejs kicks off via zoneAwareCallback, which simply invokes the callback that Angular registered
- Angular executes the wrapper around the template callback; this wrapper flags the view and all its ancestors as dirty
- Angular runs the
fetchDatacomponent method via the event listener defined in the component template function - the
fetchDataapplication logic initiates a network request - zone.js intercepts the request and schedules a macrotask with the browser
- the event gets processed, and zone.js triggers onMicrotaskEmpty via
NgZone - Angular responds by running change detection across the app through ApplicationRef.tick
- change detection refreshes the DOM and executes any side effects
- the browser paints the updates on screen, then proceeds to the network request macrotask. When the network response arrives, the entire cycle repeats, but this time the
onreadystatechangeevent listener fires instead of theclickhandler. The JavaScript phase wraps up with Angular running change detection, and then the browser moves through its rendering pipeline once more.
This lifecycle looks like this when depicted on a diagram:

We can observe all of this through the Chrome Dev Tools profiler.

The Call Stack panel reveals the execution order — select the Click event:

Expanding it downwards exposes the following:

I’ve color-coded the different actors:
- yellow – zone.js
- purple – angular
- green – application
There’s another path in the call stack that heads toward change detection:

After Angular finishes change detection, the browser proceeds through its pipeline — handling style, layout, paint, and sometimes compositing:

There’s a particularly interesting scenario to examine involving setTimeout for waiting until change detection completes.
Let’s take a closer look.
setTimeout and change detection
You’ll often come across setTimeout in application code.
Say the requirement is to display an input field and immediately give it focus when the user clicks a button.
Here’s one approach:
import { Component } from "@angular/core";
@Component({
selector: "b-cmp",
template: `
Add a new todo:
<button (click)="showSearchInput(ctrl)">Add</button>
<div [hidden]="searchInputHidden">
<input #ctrl />
</div>
`,
})
export class B {
searchInputHidden = true;
showSearchInput(ctrl) {
this.searchInputHidden = false;
setTimeout(function () {
ctrl.focus();
});
}
}
That produces this:

This code is quite simple. The app responds to user interaction, executes change detection, and refreshes the component state. Angular handles change detection and updates the DOM.
Yet, why is setTimeout necessary?
The timeout is essential because you cannot call focus() on an element that remains hidden.
Until Angular runs change detection (which won’t happen until the showSearchInput() method finishes),
the DOM’s hidden property stays untouched, even though searchInputHidden has been set to false inside the method.
By calling setTimeout() with 0 (or omitting it, which defaults to around 4ms),
we schedule a macrotask that fires only after Angular has had the chance to run change detection and update the hidden property.
Keep in mind that once the setTimeout() callback completes,
change detection will trigger again (because Angular monkey-patches every setTimeout() call within the Angular zone).
Since our async callback only adjusts the focus, we can make this more efficient
by executing the callback outside the Angular zone, skipping the extra change detection pass:
import { Component } from "@angular/core";
@Component({
selector: "b-cmp",
template: `
Add a new todo:
<button (click)="showSearchInput(ctrl)">Add</button>
<div [hidden]="searchInputHidden">
<input #ctrl />
</div>
`,
})
export class B {
searchInputHidden = true;
private showSearchInput(ctrl) {
this.searchInputHidden = false;
this._ngZone.runOutsideAngular(() => {
setTimeout(() => ctrl.focus());
});
}
}
Just remember to inject NgZone into your constructor for the code above to function:
import { NgZone } from "@angular/core";
export class B {
constructor(private _ngZone: NgZone) {}
}
And that wraps it up.
