Mert Özen Further With Every Line
Frontend Web Performance — #05

Optimizing INP: Input Delay, Processing and Presentation Delay, Breaking Up Long Tasks and a Smooth List in Angular

Mert Özen Nov 15, 2026 12 min 7 views
Optimizing INP: Input Delay, Processing and Presentation Delay, Breaking Up Long Tasks and a Smooth List in Angular

To lower INP, find in which phase the worst interaction waits (input delay, processing, presentation delay) and either break up or shrink the long task blocking the main thread. In Bookstore's 2,000-book catalog we show with LoAF that zoneless Angular's change detection runs in a frame callback, and cut INP from 416 ms to 8 ms with computed, the right track, scheduler.yield and CDK virtual scroll.

Version note: The examples in this post were run with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports, and @angular/cdk 22.2.7), web-vitals 6.2.3, .NET 10 (ASP.NET Core 10) and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158. Browser support data comes from MDN browser-compat-data 8.1.9 (2026-11-12).

Predict First

The catalog page of Bookstore, the online bookstore used as the shared example across this site's series, shows 2,000 books in one table and filters the list as you type into the box above it. In the RUM table of the first post, this page's INP on phones was "needs improvement" at 232 ms. The template and the component:

  template: `
    <h1>Catalog</h1>
    <input type="search" class="filter" placeholder="Filter by title or author"
           (input)="query.set($any($event.target).value)" />
    <p>{{ filtered().length }} books</p>
    <table class="books">
      <tbody>
        @for (book of filtered(); track $index) {
          <tr>
            <td><a [routerLink]="['/books', book.isbn]">{{ book.title }}</a></td>
            <td>{{ book.author }}</td>
            <td>{{ book.publisher }}</td>
            <td>{{ book.year }}</td>
            <td class="price">{{ formatPrice(book) }}</td>
            <td>{{ book.stock > 0 ? 'In stock' : 'Sold out' }}</td>
          </tr>
        }
      </tbody>
    </table>
  `,
export class CatalogPage {
  protected readonly books = httpResource<Book[]>(() => '/api/books');
  protected readonly query = signal('');

  protected filtered(): Book[] {
    const q = this.query().toLocaleLowerCase('en');
    return (this.books.value() ?? [])
      .filter((b) => `${b.title} ${b.author}`.toLocaleLowerCase('en').includes(q))
      .sort((a, b) => a.title.localeCompare(b.title));
  }

  protected formatPrice(book: Book): string {
    return new Intl.NumberFormat('en-US', { style: 'currency', currency: book.currency }).format(book.price);
  }
}

Before reading on, guess: if a user types "war" and then presses Backspace three times to clear the search, how long does the worst interaction take? And where does most of the time go: into filtering and sorting, or somewhere else?

The Three Phases of an Interaction

For every click, tap and key press on the page, INP measures the time from the user's action to the next frame painted on screen, and reports one of the worst over the page's lifetime (according to web.dev one highest interaction is ignored for every 50 interactions, so on most pages it is the worst one). The time splits into three phases:

  • Input delay: from the user's action until the event handlers start running. It grows when the main thread is busy with something else at that moment.
  • Processing duration: the time the event handlers run.
  • Presentation delay: from the end of the handlers until the next frame reaches the screen. Style, layout, paint and any requestAnimationFrame callbacks running in between are here.

The thresholds are the same as in the first post: 200 ms or less is "good", above 500 ms "poor". For measuring, we added a --clear option to measure.mjs: after typing "war" the script deletes every letter 150 ms apart; the last Backspace brings the list back to 2,000 rows.

Measuring and Finding the Cause

http://127.0.0.1:5300/books profile=mobile runs=5
run     TTFB    FCP    LCP    CLS    INP
1        151   1216   1216      0    416
2        151   1196   1196      0    440
3        151   1192   1192      0    416
4        150   1188   1188      0    408
5        151   1196   1196      0    416
med      151   1196   1196      0    416
LCP subparts (median): ttfb=151 loadDelay=0 loadDuration=0 renderDelay=1046
LCP element: html>body>app-root>main>app-catalog-page>h1
INP subparts (median): inputDelay=1 processing=2 presentation=4

INP is 416 ms. The median of the subparts (1, 2, 4) says nothing, because the 400 ms of work lands in a different phase in each run. The RUM report of a single run is clearer:

{
  "name": "INP",
  "value": 416,
  "rating": "needs-improvement",
  "attribution": {
    "target": "html>body>app-root>main>app-catalog-page>input.filter",
    "type": "keyboard",
    "inputDelay": 413,
    "processing": 0,
    "presentation": 3,
    "script": "FrameRequestCallback"
  }
}

In this run all of the time is input delay: when Backspace was pressed, the main thread was still doing the work of the previous key. The script field is the invoker of the longest script web-vitals found in the LoAF records (Long Animation Frames, the API that reports long frames and the scripts in them): FrameRequestCallback, a requestAnimationFrame callback. LoAF exists only in Chromium; in INP reports from Firefox and Safari this field stays empty.

So who is that callback? Since Angular 21 new projects are zoneless, and in Angular 22 components are OnPush by default. In zoneless mode Angular does not run change detection as soon as a signal changes; it schedules it with a race between setTimeout and requestAnimationFrame (scheduleCallbackWithRafRace in the @angular/core source). query.set() takes 1-2 ms in the event handler; the real work is the change detection running in whichever callback comes first: filtered() called twice in the template, each call filtering 2,000 books and sorting them with localeCompare, a new Intl.NumberFormat for every row, and updating a DOM of 16,030 elements. When that callback runs in rAF the time goes to presentation delay; when it runs in the timeout it goes to the input delay of the next interaction. The phase changes, the problem is the same: one long task of 400 ms.

Step 1: computed, One Formatter and the Right track

// One formatter for the whole list instead of one per row and change detection
const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'TRY' });
        @for (book of filtered(); track book.isbn) {
          <tr>
            <td><a [routerLink]="['/books', book.isbn]">{{ book.title }}</a></td>
            <td>{{ book.author }}</td>
            <td>{{ book.publisher }}</td>
            <td>{{ book.year }}</td>
            <td class="price">{{ book.priceText }}</td>
            <td>{{ book.stock > 0 ? 'In stock' : 'Sold out' }}</td>
          </tr>
        }
  // Prepared once when the data arrives: lower-cased search text, formatted price, sorted
  private readonly prepared = computed(() =>
    (this.books.value() ?? [])
      .map((b) => ({ ...b, search: `${b.title} ${b.author}`.toLowerCase(), priceText: price.format(b.price) }))
      .sort((a, b) => a.title.localeCompare(b.title)),
  );

  // Recomputed only when query or data change, not on every change detection
  protected readonly filtered = computed(() => {
    const q = this.query().toLowerCase();
    return this.prepared().filter((b) => b.search.includes(q));
  });

Three changes together: the search text, the formatted price and the sort order are computed once when the data arrives; because filtered is a computed it is only recalculated when query or the data change, and the two reads in the template use the same result; and track follows the book's ISBN instead of the row's position.

http://127.0.0.1:5300/books profile=mobile runs=5
run     TTFB    FCP    LCP    CLS    INP
1        152   1276   1276      0    312
2        150   1212   1212      0    288
3        151   1208   1208      0    304
4        150   1204   1204      0    296
5        151   1208   1208      0    296
med      151   1208   1208      0    296
LCP subparts (median): ttfb=151 loadDelay=0 loadDuration=0 renderDelay=1058
LCP element: html>body>app-root>main>app-catalog-page>h1
INP subparts (median): inputDelay=293 processing=0 presentation=2

INP went from 416 ms to 296 ms. Most of the remaining 300 ms is no longer computation but DOM: on the last Backspace about 2,000 rows are created again and the table is laid out from scratch. The difference between track $index and track book.isbn here is not speed but correctness: $index binds DOM rows to positions and shows a different book in the first row after filtering; if the row has a checkbox, an open menu or an animation, it moves to the wrong book. Tracking the book itself (track book) is a trap too: every list that comes back from the API again means new objects, and all rows are destroyed and rebuilt. A stable key (the ISBN) avoids both.

Step 2: Breaking Up the Long Task with scheduler.yield

The first thing a user needs to see after pressing a key is the letter appearing in the box; updating the list right after that is enough. So we split the event handler in two: first we return to the main thread, the list update runs in a new task, and the browser can fit a frame in between:

// scheduler.yield(): Chrome 129+, Firefox 142+; Safari has no Scheduler API yet -> setTimeout fallback.
// The continuation of scheduler.yield() runs before other queued tasks, setTimeout's at the back.
export function yieldToMain(): Promise<void> {
  const scheduler = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
  if (scheduler?.yield) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}
  // End the input task here: the list update runs in a new task, the browser can paint before it
  protected async onInput(value: string): Promise<void> {
    await yieldToMain();
    this.query.set(value);
  }

According to browser-compat-data, scheduler.yield() is in Chrome 129 and Firefox 142 but not in Safari, so a fallback to setTimeout is required. The difference described in the Chrome team's article matters: the continuation of scheduler.yield() runs before other queued tasks, while that of setTimeout(0) goes to the back of the queue.

http://127.0.0.1:5300/books profile=mobile runs=5
run     TTFB    FCP    LCP    CLS    INP
1        152   1288   1288      0    128
2        150   1216   1216      0    128
3        151   1216   1216      0     64
4        151   1208   1208      0     64
5        151   1208   1208      0    120
med      151   1216   1216      0    120
LCP subparts (median): ttfb=151 loadDelay=0 loadDuration=0 renderDelay=1066
LCP element: html>body>app-root>main>app-catalog-page>h1
INP subparts (median): inputDelay=55 processing=2 presentation=61

INP went from 296 ms to 120 ms and is now below the "good" threshold. But the work did not shrink: the 300 ms render is still there, it just runs after the interaction's frame. Its trace is in the input delay (median 55 ms): the next key of a fast typist waits for that render to finish. Yielding is a good tool for giving the user immediate feedback, but it does not make the work smaller.

Step 3: Doing Less Work, CDK Virtual Scroll

A phone screen shows at most 20-25 rows at a time; the only result of keeping 2,000 rows in the DOM is that the browser lays them all out. Angular CDK's virtual scroll renders only the rows in and just around the viewport and refills the same DOM elements with new books as you scroll:

npm install @angular/cdk@22.2.7
  template: `
    <h1>Catalog</h1>
    <input type="search" class="filter" placeholder="Filter by title or author"
           (input)="onInput($any($event.target).value)" />
    <p>{{ filtered().length }} books</p>
    <!-- Only the rows in (and just around) the viewport exist in the DOM -->
    <cdk-virtual-scroll-viewport itemSize="37" class="books">
      <div *cdkVirtualFor="let book of filtered(); trackBy: byIsbn" class="row">
        <a [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
        <span>{{ book.author }}</span>
        <span>{{ book.publisher }}</span>
        <span>{{ book.year }}</span>
        <span class="price">{{ book.priceText }}</span>
        <span>{{ book.stock > 0 ? 'In stock' : 'Sold out' }}</span>
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: `
    .filter { width: 100%; padding: 10px 12px; font-size: 16px; }
    .books { height: 70vh; font-size: 14px; }
    .row { display: grid; grid-template-columns: 3fr 2fr 2fr 1fr 1fr 1fr; gap: 8px; height: 37px;
           align-items: center; padding: 0 8px; border-bottom: 1px solid #e2e8f0; overflow: hidden; }
    .price { text-align: right; white-space: nowrap; }
  `,
})
export class CatalogPage {
  protected readonly books = httpResource<Book[]>(() => '/api/books');
  protected readonly query = signal('');
  protected readonly byIsbn = (_: number, book: Book) => book.isbn;

Virtual scroll needs rows of a fixed height (itemSize="37"); instead of a table we use divs laid out with grid. The final state, with virtual scroll and yield together:

http://127.0.0.1:5300/books profile=mobile runs=5
run     TTFB    FCP    LCP    CLS    INP
1        152   1404   1404      0     32
2        151   1332   1332      0      8
3        150   1332   1332      0     24
4        151   1336   1336      0      8
5        150   1340   1340      0      8
med      151   1336   1336      0      8
LCP subparts (median): ttfb=151 loadDelay=0 loadDuration=0 renderDelay=1186
LCP element: html>body>app-root>main>app-catalog-page>h1
INP subparts (median): inputDelay=1 processing=0 presentation=8
StateINP (ms, median)Note
A: start416template method, a formatter per row, track $index
B: computed, one formatter, track book.isbn296computation gone, DOM remains
D: B + scheduler.yield()120same work, moved out of the interaction
C: B + CDK virtual scroll24DOM from 16,030 to 193 elements
E: C + scheduler.yield() (final)816 on the low-end phone profile

The number of elements on the page went from 16,030 to 193 and the worst interaction takes 8 ms. We measured the cost as well: CDK's scrolling module grew main.js from 316 KB to 376 KB and the catalog page's FCP went from 1.2 s to 1.34 s, because the catalog route is not lazy-loaded. We will win that back with lazy routes in the post on JavaScript cost.

Other Tools and When to Use Them

Virtual scroll is not right for every list. The browser's find in page (Ctrl+F) only finds rows in the DOM, screen readers cannot know the total length of the list, and a fixed row height does not fit every design. A lighter alternative is content-visibility: auto in CSS: the elements stay in the DOM but layout and paint are skipped for the ones off screen (Chrome 85, Firefox 125, Safari 18 according to browser-compat-data); you need to give an estimated size with contain-intrinsic-size, otherwise the scrollbar jumps. We did not measure it in this post.

Debouncing means filtering when the user stops typing instead of on every key; when the search goes to the server, it also cuts the number of requests. The basics are in the "JavaScript Performance Tips: Debounce, Throttle and Layout Thrashing" post of the Advanced JavaScript series. For really heavy computation (sorting a large dataset, parsing a PDF) the right place is not the main thread but a Web Worker; that topic belongs to the Browser APIs and PWA series. An Angular-specific source is effect chains: writing a signal into another signal inside an effect means an extra round of change detection on every change; use computed for derived values.

In Production

Group INP in RUM by the target of the interaction. The attribution.target (e.g. input.filter) and type (keyboard, pointer) we send since the first post make it possible to say "the search box on the catalog is slow"; a p75 per route alone does not tell you which button is slow. Keep the script information from LoAF too, but the names and URLs of third-party scripts show up there as well; mind who can see the RUM data.

In the lab, INP only measures the scenario you wrote. The problem in this post showed up when the user cleared the search; when they only typed "war" (the first post's simulation) INP was 232 ms. Real users know the scenarios better than you; the target of the worst interaction in the field data tells you what to try in the lab. Finally, since Safari has no scheduler.yield(), the continuation of the fallback setTimeout goes to the back of the queue; on busy pages the list update may arrive a little later there.

Common Pitfalls

  • Calling an expensive method in the template. It runs again on every change detection; called twice in the same template, twice. Use computed for derived values.
  • Binding list rows to positions with track $index, or tracking the object itself in a list that gets new objects on every refresh.
  • Creating new Intl.NumberFormat, new Intl.DateTimeFormat or new RegExp inside a loop.
  • Looking only at processing duration. In zoneless Angular the real work often shows up in a frame callback, that is in presentation delay or in the input delay of the next interaction.
  • Using scheduler.yield() without a fallback; Safari has no scheduler and the code throws.
  • Treating yielding as a replacement for doing less work. INP drops, but the long task stays.
  • Measuring INP only during page load. By definition it covers the whole lifetime of the page.

A Small Experiment

★☆☆ Run measure.mjs for the final state with --type tolstoy --clear --profile lowend. Which of the three phases of INP is the largest?

★★☆ Go back to state B and change track book.isbn in @for to track book. Measure INP with a change that calls the API again on every search (e.g. pass query to httpResource as a parameter); watch the rows being rebuilt in the DevTools Elements panel.

★★★ Keep the table and give the tbody rows content-visibility: auto and contain-intrinsic-size: auto 33px. Compare INP with virtual scroll, search for a book at the end of the list with Ctrl+F and note the difference between the two solutions.

The catalog's INP went from 416 ms to 8 ms. In the next post we return to CLS: how a shift is calculated (impact and distance, session window), the 500 ms exception after user input, and the "Similar books" block on the book page pushing the page when it loads, together with the size of @defer placeholders.