INP Angular 17+

Fix INP in Angular

INP in Angular 17+ apps is dominated by Zone.js overhead: every browser event triggers a full component tree change detection pass, which on large apps adds hundreds of milliseconds to each interaction's processing time before the browser can paint. The fixes in this guide address that root cause systematically, using OnPush change detection, NgZone.runOutsideAngular(), trackBy, Angular 17 deferrable views, and scheduler.yield() for genuinely long synchronous tasks.

Expected results

Before

450ms

INP (Poor) -- Zone.js overhead, no OnPush, all ngFor without trackBy, sync heavy handlers

After

140ms

INP (Good) -- OnPush everywhere, NgZone.runOutsideAngular, trackBy, deferrable views

Step-by-step fix

Enable ChangeDetectionStrategy.OnPush site-wide

With the default change detection strategy, every Zone.js notification -- a click anywhere in the page, a timer, an HTTP response -- triggers Angular to check every component in the tree from root to leaf. On a non-trivial app this can involve hundreds of components per interaction. OnPush short-circuits this: Angular skips a component and its subtree unless one of its signal or Input values changes reference, it fired an event, or markForCheck() was called. Angular 17 signals integrate naturally with OnPush because signal reads register component-level subscriptions automatically.

Angular -- OnPush with signals
import {
  Component, ChangeDetectionStrategy,
  input, signal, computed, inject
} from '@angular/core';
import { ProductService } from './product.service';

@Component({
  selector: 'app-product-grid',
  standalone: true,
  // Opt in once -- signals handle invalidation automatically
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input [value]="query()" (input)="query.set($any($event.target).value)"
           placeholder="Search products" />
    <ul>
      @for (p of filteredProducts(); track p.id) {
        <li>{{ p.name }} -- {{ p.price | currency }}</li>
      }
    </ul>
  `,
})
export class ProductGridComponent {
  private svc = inject(ProductService);

  // Input signals trigger re-render only on new reference
  category = input.required<string>();

  // Local signals -- updating them marks this view for check
  query = signal('');

  filteredProducts = computed(() =>
    this.svc.products()
      .filter(p => p.category === this.category())
      .filter(p => p.name.toLowerCase().includes(this.query().toLowerCase()))
  );
}

Run event handlers outside Zone.js

Scroll listeners, resize observers, pointer-move handlers, and analytics calls do not need to trigger Angular rendering. Running them inside Zone.js causes change detection to fire on every scroll tick or mouse move -- potentially dozens of times per second. Inject NgZone and call runOutsideAngular() to register these handlers outside Zone.js's tracking. When you do need to update the UI based on the result, re-enter Zone.js with ngZone.run() or call ChangeDetectorRef.detectChanges() directly.

Angular -- NgZone.runOutsideAngular
import {
  Component, OnInit, OnDestroy,
  NgZone, ChangeDetectorRef, inject, signal
} from '@angular/core';

@Component({
  selector: 'app-scroll-tracker',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>Scroll position: {{ scrollY() }}px</p>`,
})
export class ScrollTrackerComponent implements OnInit, OnDestroy {
  private ngZone = inject(NgZone);
  private cdr = inject(ChangeDetectorRef);

  scrollY = signal(0);
  private handler!: () => void;

  ngOnInit(): void {
    // Bad: addEventListener inside Zone triggers change detection on every scroll
    // window.addEventListener('scroll', () => this.scrollY.set(window.scrollY));

    // Good: run outside Zone -- no CD on every scroll tick
    this.ngZone.runOutsideAngular(() => {
      this.handler = () => {
        const y = window.scrollY;
        // Re-enter Zone only when value meaningfully changes
        if (Math.abs(y - this.scrollY()) > 50) {
          this.ngZone.run(() => {
            this.scrollY.set(y);
            // ChangeDetectorRef.detectChanges() also works with OnPush
          });
        }
      };
      window.addEventListener('scroll', this.handler, { passive: true });
    });
  }

  ngOnDestroy(): void {
    window.removeEventListener('scroll', this.handler);
  }
}

Add trackBy to all *ngFor directives

Without trackBy, Angular compares list items by object identity. When data is fetched fresh from an API -- returning new object references even when the content is the same -- Angular destroys all existing DOM nodes and recreates them from scratch. For a 50-item list this means 50 node destructions and 50 node creations per change detection cycle, blocking the main thread and inflating INP. A trackBy function returning a stable identifier (typically item.id) lets Angular reuse existing nodes and only update the parts that changed.

Angular -- trackBy on ngFor
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { NgFor } from '@angular/common';

interface Order { id: number; customer: string; total: number; }

@Component({
  selector: 'app-order-table',
  standalone: true,
  imports: [NgFor],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- Bad: no trackBy -- entire list re-created on each data refresh -->
    <tr *ngFor="let order of orders()">
      <td>{{ order.id }}</td>
      <td>{{ order.customer }}</td>
    </tr>

    <!-- Good: trackBy by id -- only changed rows get DOM updates -->
    <tr *ngFor="let order of orders(); trackBy: trackById">
      <td>{{ order.id }}</td>
      <td>{{ order.customer }}</td>
    </tr>

    <!-- Angular 17+ @for syntax: track is mandatory and built-in -->
    @for (order of orders(); track order.id) {
      <tr>
        <td>{{ order.id }}</td>
        <td>{{ order.customer }}</td>
      </tr>
    }
  `,
})
export class OrderTableComponent {
  orders = signal<Order[]>([]);

  // trackBy function for *ngFor
  trackById(_index: number, order: Order): number {
    return order.id;
  }
}

Use @defer (on interaction) for heavy components

Heavy components -- rich text editors, data visualisation libraries, drag-and-drop surfaces -- add significant JavaScript parse and execution time to the initial load. That blocking cost delays the browser's ability to respond to the user's first interaction. Angular 17's @defer with the on interaction trigger keeps those chunks out of the initial bundle entirely. The chunk downloads and executes only when the user first interacts with the trigger element, by which time initial interactions with other page content have already been responded to.

Angular -- @defer with on interaction trigger
<!-- editor-page.component.html -->

<!-- Trigger element -- the defer block loads when user clicks this -->
<button #editorTrigger (click)="showEditor.set(true)">
  Open Editor
</button>

<!-- Bad: heavy editor is in the main bundle, parsed at startup -->
@if (showEditor()) {
  <app-rich-text-editor />
}

<!-- Good: editor chunk loads only on first interaction -->
@defer (on interaction(editorTrigger)) {
  <app-rich-text-editor />
} @placeholder {
  <div class="editor-placeholder" style="min-height: 400px;">
    Click to activate editor
  </div>
} @loading (minimum 150ms) {
  <div class="editor-loading" style="min-height: 400px;">
    Loading editor...
  </div>
} @error {
  <p>Editor failed to load. Please refresh.</p>
}

<!-- For chart libraries: defer on viewport instead -->
@defer (on viewport; prefetch on idle) {
  <app-analytics-chart [data]="chartData()" />
} @placeholder {
  <div style="height: 320px; background: var(--color-surface-2);"></div>
}

Break long tasks with scheduler.yield()

Some synchronous work cannot be avoided in a click handler -- sorting a large dataset, running a filter algorithm, serialising state. If this work takes more than 50ms it blocks the main thread and the browser cannot paint the response to the interaction until the work finishes. scheduler.yield() (available in Chrome 115+ with a polyfill elsewhere) inserts a yield point that lets the browser paint a frame and process any higher-priority input before continuing. Split work into batches and await a yield between each batch to keep individual tasks under 50ms.

Angular -- scheduler.yield() for long tasks
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
import { ChangeDetectorRef } from '@angular/core';

// Polyfill for environments without scheduler.yield
function yieldToMain(): Promise<void> {
  if ('scheduler' in window && 'yield' in (window as any).scheduler) {
    return (window as any).scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

@Component({
  selector: 'app-report-builder',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button (click)="buildReport()" [disabled]="processing()">
      {{ processing() ? 'Processing...' : 'Build Report' }}
    </button>
    <p>Processed {{ processedCount() }} of {{ totalCount() }} rows</p>
  `,
})
export class ReportBuilderComponent {
  private cdr = inject(ChangeDetectorRef);

  processing = signal(false);
  processedCount = signal(0);
  totalCount = signal(0);

  async buildReport(): Promise<void> {
    const rows = await this.fetchRawData(); // assume large dataset
    const BATCH_SIZE = 200;

    this.processing.set(true);
    this.totalCount.set(rows.length);
    this.cdr.detectChanges();

    const results: ProcessedRow[] = [];

    for (let i = 0; i < rows.length; i += BATCH_SIZE) {
      const batch = rows.slice(i, i + BATCH_SIZE);
      results.push(...batch.map(row => this.processRow(row)));
      this.processedCount.set(i + batch.length);
      this.cdr.detectChanges(); // update progress indicator

      // Yield to browser between batches -- keeps each task under 50ms
      await yieldToMain();
    }

    this.processing.set(false);
    this.cdr.detectChanges();
  }

  private processRow(row: RawRow): ProcessedRow {
    // CPU-intensive transformation
    return { ...row, computed: row.value * 1.2 };
  }

  private async fetchRawData(): Promise<RawRow[]> {
    return fetch('/api/report-data').then(r => r.json());
  }
}

interface RawRow { id: number; value: number; }
interface ProcessedRow extends RawRow { computed: number; }

Quick checklist

  • Every component uses ChangeDetectionStrategy.OnPush
  • Scroll, resize, and analytics handlers run inside NgZone.runOutsideAngular()
  • All *ngFor directives have a trackBy function (or use @for with track)
  • Heavy components use @defer with on interaction or on viewport
  • Long synchronous tasks are split into batches with scheduler.yield() between them
  • INP measured in DevTools Performance tab and verified below 200ms with real-user onINP()

Frequently asked questions

Zone.js patches every browser async API -- setTimeout, Promise, fetch, addEventListener -- and notifies Angular after each one resolves. On a default-strategy app this triggers a full component tree check for every click, keypress, and timer. With a large component tree this processing adds tens to hundreds of milliseconds to each interaction before the browser can paint the response, directly increasing INP. Removing Zone.js entirely (zoneless mode, experimental in Angular 17) or using NgZone.runOutsideAngular() for non-UI work eliminates this overhead.

ChangeDetectionStrategy.OnPush tells Angular to skip a component and its entire subtree during change detection unless an @Input or signal value has a new reference, an event fired inside the component, or markForCheck() was called. This can reduce the number of components checked per interaction from every component in the tree to a small constant, cutting processing time proportionally. Angular 17 signals work seamlessly with OnPush because signal reads automatically register the consuming component for re-check when the signal updates.

Yes, significantly for large lists. Without trackBy, Angular compares list items by object identity. When data comes from an API returning new object references, Angular destroys all existing DOM nodes and creates new ones from scratch -- even when the data content is the same. For a 100-item list this means 100 destructions and 100 creations per cycle. trackBy with a stable ID lets Angular match old nodes to new data and only mutate the nodes that actually changed, reducing DOM work by 90% or more in common scenarios.

@defer splits heavy components into separate JavaScript chunks excluded from the initial bundle. With on interaction, Angular only downloads and executes the chunk when the user first clicks the trigger element. This means the heavy component's parse and execution cost does not block initial page interactions. Once downloaded, subsequent interactions with that component are fast because the code is in the module cache. Use prefetch on idle alongside the trigger to speculatively fetch the chunk during idle time so it is ready by the time the user interacts.

Open Chrome DevTools Performance tab, check the Web Vitals checkbox, and record while clicking or typing. Interactions with long processing times are flagged in the timeline. Look for red-cornered long tasks on the main thread thread during the event. The Angular DevTools browser extension shows per-component change detection timing, which identifies which components contribute the most processing time per interaction. For field data, install the web-vitals package and call onINP(console.log) in main.ts.

Use Chrome DevTools Performance panel with CPU throttling (4x slowdown) to simulate mid-range mobile devices. Interact with the page (click buttons, type in inputs, open menus) and look for long tasks in the flame chart. The Web Vitals Chrome Extension shows real-time INP scores as you interact. For Angular, pay attention to hydration-related interaction delays.

Continue learning