Sitemap
Genildo cerqueira

Genildo cerqueira

Press enter or click to view image in full size
image by gemini

Beyond OnPush: The 5 Layers of Extreme Performance in Angular 22

5 min read6 days ago

--

Angular 22 solved the oldest problem in your application: OnPush is now the default for any new component. What the classic tutorial doesn’t tell you is what happens next.

Enabling OnPush is like inflating the tires on a race car. It helps, but it won’t win the race if the engine is misaligned.

To reach extreme performance in production, you need to master the map of the five performance layers. They are complementary, not alternatives. Real speed doesn’t come from running faster, but from doing less unnecessary work.

The Map of the 5 Performance Layers

Layer Technique Estimated Impact Bottleneck Solved 1 Zoneless Up to 40% less CD overhead The cost of Zone.js’s automatic monitoring 2 OnPush 2x fewer state checks Checks on components that haven’t changed 3 @defer 40% to 50% LCP improvement with SSR Parsing and executing heavy off-screen components 4 Virtual Scroll Stable DOM (~12 nodes vs. 5,000+) The browser’s physical slowness when rendering lists 5 Correct track Physical DOM recycling Full element re-creation on reorderings

Layer 1: Zoneless in Production (Without Breaking the UI)

Removing Zone.js removes the middleman. Angular stops eagerly monitoring every asynchronous browser task. The reduction in rendering overhead reaches 40%.

However, zoneless mode (configured via provideExperimentalZonelessChangeDetection()) exposes a painful truth in enterprise applications: third-party libraries break silently.

The danger of third-party dependencies

Charts, rich-text editors, maps and legacy drag-and-drop plugins were written assuming Zone.js would notify Angular about their asynchronous updates. In a zoneless architecture, these libraries work on their own, but the Angular UI around them stays static and frozen.

The Solution: Don’t patch the code with global hacks. Wrap the initialization of sensitive libraries using runInInjectionContext(), or build an encapsulated adapter with Signals to force change notifications directly in the component's lifecycle.

Layer 2: The New Meaning of “Default”

For years, not defining the change detection strategy meant inheriting Angular’s default behavior. In Angular 22, that changed. The old ChangeDetectionStrategy.Default was deprecated and renamed to ChangeDetectionStrategy.Eager.

// Angular v22 adds this explicitly during the automatic migration:
@Component({
...
changeDetection: ChangeDetectionStrategy.Eager // The old "Default"
})

“Eager” means eager. The component is checked on every render cycle, with no distinction. When you update your project, the Angular CLI automatically converts the old behavior to the Eager flag.

Don’t treat these markers as bugs. They are your code modernization map. Every file containing the Eager keyword is a component that still needs to be refactored to Signals and OnPush.

Layer 3: Strategic @defer and the LCP Case

@defer is not just route-level lazy loading. It controls exactly when — and under what condition — the user pays the network, parse and compile cost of a component on screen.

The 7 Triggers of Asynchrony

  1. on idle: Loads when the browser is idle (ideal for hidden tabs and below-the-fold content).
  2. on viewport: Loads when the element physically enters the visible area of the screen.
  3. on interaction: Loads only if the user interacts with the placeholder (modals and drawers).
  4. on hover: Loads when the cursor hovers over the component (dropdowns and tooltips).
  5. on timer(Xms): Loads after a predefined delay.
  6. when condition: Imperative control through an application state or Signal.
  7. on immediate: Loads as soon as the main chunk is processed.

The Silent Defer Gotcha

For a component to be truly deferred, it must be Standalone. If you place a traditional NgModule-based component inside a @defer block, the compiler won't throw errors. It will simply ignore the lazy-loading instruction and render the component eagerly.

The Impact on SSR (Lighthouse)

When using Server-Side Rendering (SSR), the @placeholder block is rendered immediately in the first byte from the server, while the heavy, interactive component is pushed to the client asynchronously. This architectural isolation results in a real 40% to 50% improvement in the LCP (Largest Contentful Paint) metric.

Layer 4: Virtual Scroll (The End of DOM Weight)

Classic rendering of long lists destroys browser performance. Rendering 5,000 items at once creates severe memory bottlenecks and freezes scrolling.

Get Genildo cerqueira’s stories in your inbox

Join Medium for free to get updates from this writer.

With Virtual Scroll (using *cdkVirtualFor), the DOM keeps only about 12 active elements (just what is actually visible on screen). The rest of the page height is simulated by an invisible container element.

Production Benchmark (5,000-item list)

  • Standard *ngFor (no optimization): 4,200ms
  • *ngFor + OnPush: 2,100ms
  • *cdkVirtualFor (Virtual Scroll): 3.4ms

The Rule of Thumb: Only use virtual scroll if the list exceeds 100 items and each row has a fixed, predictable height. Computing height dynamically at runtime (autosize) adds a measurement overhead that can make the component slower than rendering the list the traditional way.

Layer 5: The Silent Danger of track $index

Angular 22 makes declaring track mandatory in the @for syntax. This prevents forgetting it, but opened the door to the most common anti-pattern in the ecosystem: the indiscriminate use of track $index.

<!-- SILENT ANTI-PATTERN (Destroys DOM recycling): -->
@for (item of itens; track $index) {
<meu-componente [dado]="item"></meu-componente>
}

Tracking elements by array index tells Angular: “The element’s position in the DOM is its identity”. If you filter the list, sort the items or insert a new element at the top, Angular is forced to physically destroy and re-create every UI element, because their indexes changed.

When to use $index?

Tracking by index is only correct if your list is completely immutable (the order and items never change after the initial load). Example: the fixed steps of a sign-up wizard.

For any dynamic list, always use a real unique identity property:

<!-- CORRECT PATH (Performance and real DOM reuse): -->
@for (item of itens; track item.id) {
<meu-componente [dado]="item"></meu-componente>
}

Measurement Guide (Before Optimizing)

Optimizing without data is just guessing. Before rewriting your code, use Angular DevTools and Lighthouse to map where your application spends CPU cycles.

Production Metric Before After the 5 Layers LCP (Largest Contentful Paint) 3.8s 1.9s (with @defer + SSR) TBT (Total Blocking Time) 680ms 120ms (with @defer + zoneless) Initial Render (5k items) 4,200ms 3.4ms (with virtual scroll) CD cycles per interaction ~200 components ~12 components (OnPush + zoneless) DOM nodes (long list) 10,000 nodes ~12 nodes (with virtual scroll) Initial Bundle 380KB gzip 180KB gzip (with strategic @defer)

Performance Checklist for the Pull Request (PR)

Before approving any frontend code in your repository, make sure the following 4 rules are met:

  1. Real Identity: Every dynamic @for tracks by a unique ID, avoiding track $index.
  2. Standalone Separation: Heavy components inside @defer are marked standalone: true.
  3. Controlled Scale: Lists that display more than 100 items use the cdk-virtual-scroll-viewport structure.
  4. No Debt: No new component is being introduced using the deprecated ChangeDetectionStrategy.Eager strategy.

The fastest application isn’t the one that processes data in the least time. It’s the one that does no unnecessary work.

--

--