SuperAPI AI Directory

frontend-perf-core-web-vitals-inp

Frontend & websites

View repository

Updated 10-16 08:09

About this skill

Frontend Perf : Core Web Vitals + INP

This skill is the operational reference for measuring and improving the 2026 Core Web Vitals (LCP, INP, CLS), with deep coverage of INP since it replaced FID as a Core Web Vital in March 2024. It covers the binding 75th-percentile thresholds, the three INP phases, the yielding ladder, LCP and CLS tactics, the LongAnimationFrame attribution surface, and the Speculation Rules API folded in per D-R10 because it is Chromium-only Limited Availability. The skill does NOT cover animation-specific performance (see [[frontend-perf-animation-gpu-containment]]), CSS containment for layout / paint / size isolation, or RUM tool integration (Datadog, Cloudflare, Vercel).

Quick Reference

Floor rules

  • ALWAYS set fetchpriority="high" on the LCP <img> AND give it explicit width / height attributes. NEVER use loading="lazy" on the LCP image.
  • ALWAYS reserve space for late-loading content : width and height on images and iframes, aspect-ratio for fluid embeds, min-height for ad and embed slots. NEVER let a late asset push existing content downward.
  • ALWAYS break long synchronous work in event handlers with await scheduler.yield() plus a setTimeout(_, 0) fallback. NEVER ship a single >50 ms synchronous task on the main thread on a critical interaction path.
  • ALWAYS apply the visual update FIRST in an event handler, then defer compute past the paint via requestAnimationFrame(() => setTimeout(work, 0)). NEVER do heavy compute before the DOM update : the user sees a slow tap response.
  • ALWAYS feature-detect scheduler.yield() and Speculation Rules. NEVER assume Baseline ; both are Limited Availability (Chromium-only) as of 2026-05-19.
  • ALWAYS use font-display: swap with size-adjust / ascent-override / descent-override on a matched fallback. NEVER use font-display: block (three-second invisible text period kills LCP).
  • ALWAYS exclude destructive URLs from Speculation Rules (/logout, ?add-to-cart=, [rel~=nofollow], sign-in flows, OTP submission, ad-conversion endpoints). NEVER ship a blanket where: { href_matches: "/*" } prerender rule without a not clause for side-effecting paths.
  • ALWAYS pair content-visibility: auto with contain-intrinsic-size: auto Npx. NEVER use content-visibility: auto alone ; off-screen children collapse to zero and the scrollbar jitters.
  • ALWAYS evaluate all three vitals at the 75th percentile of page loads, segmented mobile vs desktop. NEVER claim "passes Core Web Vitals" until that holds at p75.

Decision tree 1 : What is hurting LCP ?

Hero image is the LCP candidate AND it loads from a known URL ?
  -> <img src="..." fetchpriority="high" loading="eager" width="W" height="H" alt="..." />
     Add <link rel="preload" as="image" href="hero.avif" fetchpriority="high"
                imagesrcset="..." imagesizes="..."> to start fetch during HTML parse.

Hero element contains text rendered in a webfont ?
  -> <link rel="preload" as="font" type="font/woff2" href="..." crossorigin>
     @font-face uses font-display: swap with size-adjust + ascent-override
     + descent-override matched to the fallback font metric.

LCP element is rendered late by client-side JavaScript ?
  -> Move to server-rendered HTML, OR preload the resource so the browser
     can fetch in parallel with JS evaluation.

Render-blocking script in <head> with no defer ?
  -> Add defer or async, code-split, ship only above-fold JS in the entry chunk.

Decision tree 2 : Why is INP > 200 ms ?

Input delay (gap between gesture and first event callback) too high ?
  -> Reduce script evaluation during load (defer non-critical, code-split).
     Move large hydration steps off the critical path or chunk them.
     Audit third-party scripts ; defer or replace.

Processing duration (handler running too long) too high ?
  -> await scheduler.yield() (or postTask fallback) BETWEEN sub-tasks in a loop.
     Gate yielding on navigator.scheduling.isInputPending() when supported.
     Move CPU-heavy work to a Web Worker.
     Batch DOM reads then writes to avoid layout thrash.

Presentation delay (gap from last callback to next paint) too high ?
  -> Apply visual update FIRST in the handler.
     Defer remaining work via requestAnimationFrame(() => setTimeout(work, 0)).
     Use content-visibility: auto + contain-intrinsic-size for off-screen lists.
     Avoid client-side HTML rendering on critical interaction paths.

Decision tree 3 : Yield API choice

scheduler.yield exists on globalThis.scheduler ?
  -> await scheduler.yield()
     Best : boosted task queue, inherits priority from postTask context.

scheduler.postTask exists ?
  -> await scheduler.postTask(() => {}, { priority: "user-visible" })
     Second choice : explicit priority, more verbose.

Neither exists ?
  -> await new Promise((r) => setTimeout(r, 0))
     Fallback ; clamped to >=4 ms after 5 nested calls, no priority awareness.

NEVER use as a generic yield :
  -> requestAnimationFrame : runs BEFORE next paint, keeps frame busy.
  -> requestIdleCallback : may starve indefinitely on a busy thread.

Decision tree 4 : Speculation Rules eagerness selection

Static site, predictable navigation, small payloads, no destructive URLs in scope ?
  -> "eagerness": "immediate" on a urls list. Prerender on page load.
     Chrome cap : 50 prefetch, 10 prerender URLs at immediate.

Dynamic app, moderate-confidence next-page prediction, mostly-static destinations ?
  -> "eagerness": "moderate" on a where rule. Triggers on 200 ms hover
     OR pointerdown (whichever first). Limited to 2 FIFO slots.

User-intent confirmation critical (auth, payment, account changes) ?
  -> "eagerness": "conservative" on a where rule. Triggers on pointerdown only.
     This is the DEFAULT for where rules ; set it explicitly to document intent.

Forward-compat slot between immediate and moderate ?
  -> "eagerness": "eager" — currently behaves like immediate, reserved for
     future tuning. Use only when documented.

Thresholds (binding 2026)

Metric Good (p75) Needs improvement Poor
LCP <= 2 5 s 2 5 s to 4 0 s > 4 0 s
INP <= 200 ms 200 ms to 500 ms > 500 ms
CLS <= 0 1 0 1 to 0 25 > 0 25

All three measured at the 75th percentile, segmented mobile vs desktop. INP replaced FID in March 2024. Source : web.dev : Vitals (verified 2026-05-19).

Patterns

Pattern : LCP image with fetchpriority and preload

<head>
  <link
    rel="preload"
    as="image"
    href="/img/hero-1280.avif"
    imagesrcset="/img/hero-640.avif 640w, /img/hero-1280.avif 1280w, /img/hero-1920.avif 1920w"
    imagesizes="100vw"
    fetchpriority="high"
  />
</head>
<body>
  <picture>
    <source type="image/avif" srcset="/img/hero-640.avif 640w, /img/hero-1280.avif 1280w" sizes="100vw" />
    <img
      src="/img/hero-1280.jpg"
      srcset="/img/hero-640.jpg 640w, /img/hero-1280.jpg 1280w"
      sizes="100vw"
      width="1280"
      height="720"
      alt="..."
      fetchpriority="high"
      loading="eager"
      decoding="async"
    />
  </picture>
</body>

fetchpriority="high" upgrades fetch priority. loading="eager" is the default ; set explicitly to document intent on the LCP element. width and height attributes reserve the layout box.

Pattern : production-grade yielding

async function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return globalThis.scheduler.yield();
  }
  if (globalThis.scheduler?.postTask) {
    return globalThis.scheduler.postTask(() => {}, { priority: "user-visible" });
  }
  return new Promise((r) => setTimeout(r, 0));
}

async function processItems(items, work) {
  for (const item of items) {
    work(item);
    if (navigator.scheduling?.isInputPending?.()) {
      await yieldToMain();
    }
  }
}

Yield BETWEEN sub-tasks (inside the loop), not once at the top.

Pattern : visual-first event handler

button.addEventListener("click", async () => {
  showSpinner();                                  // visual update first

  await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
  // The browser paints the spinner before we continue.

  const result = await runHeavyComputation();
  renderResult(result);
});

The rAF -> setTimeout(0) chain guarantees that the spinner paint commits before the next script block runs, so the user sees an immediate response even when subsequent work is heavy.

Pattern : explicit dimensions to prevent CLS

<img src="thumb.avif" width="320" height="180" alt="..." />

<iframe src="..." width="560" height="315" loading="lazy" title="..."></iframe>

<div class="ad-slot" style="aspect-ratio: 300 / 250; min-height: 250px;"></div>

For fluid images : <img style="aspect-ratio: 16 / 9; width: 100%;" ...> combines a known intrinsic ratio with responsive sizing.

Pattern : matched fallback font with size-adjust

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-variable.woff2") format("woff2-variations");
  font-display: swap;
  font-weight: 100 900;
}

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}

When Inter swaps in, the metric deltas are sub-pixel and the CLS contribution stays under 0 1.

Pattern : content-visibility for off-screen lists

.list-row {
  content-visibility: auto;
  contain-intrinsic-size: auto 56px;
}

The browser skips layout / paint / style for off-screen rows until they approach the viewport. contain-intrinsic-size provides the size estimate so the scrollbar stays stable.

Pattern : Speculation Rules with destructive-URL exclusion

<script type="speculationrules">
{
  "prerender": [{
    "source": "document",
    "where": {
      "and": [
        { "href_matches": "/*" },
        { "not": { "href_matches": "/logout" } },
        { "not": { "href_matches": "/sign-in/*" } },
        { "not": { "href_matches": "/*\\?*(^|&)add-to-cart=*" } },
        { "not": { "selector_matches": "[rel~=nofollow]" } },
        { "not": { "selector_matches": ".no-prerender" } }
      ]
    },
    "eagerness": "moderate"
  }]
}
</script>

Feature-detect : HTMLScriptElement.supports?.("speculationrules"). Server-side : honor the Sec-Purpose: prefetch request header and defer side effects on prerender. Client-side : gate work on document.prerendering and the prerenderingchange event.

Pattern : LongAnimationFrame observation

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.blockingDuration > 100) {
      const worst = (entry.scripts ?? []).reduce(
        (a, b) => (a.duration > b.duration ? a : b),
        { duration: 0 },
      );
      console.warn("LoAF blocked", entry.blockingDuration, "ms ; worst script", worst.invoker, worst.sourceURL);
    }
  }
});
observer.observe({ type: "long-animation-frame", buffered: true });

PerformanceScriptTiming attribution is same-origin only ; cross-origin iframes, web workers, and service workers report null attribution.

Metric regression to tactic (decision matrix)

Symptom Metric First tactic Second tactic
Hero image renders late LCP fetchpriority="high" + width / height <link rel="preload" as="image" imagesrcset>
Webfont flash in LCP element LCP + CLS preload font + font-display: swap matched fallback with size-adjust
Render-blocking JS LCP defer or async code-split entry
Click handler > 100 ms INP processing yield via scheduler.yield() between sub-tasks Web Worker
Slow tap response INP presentation visual update first ; rAF -> setTimeout(0) precompute on hover / idle
Typeahead lag INP input delay debounce 150 to 300 ms via AbortController requestIdleCallback for reindex
Off-screen list jank INP processing content-visibility: auto + contain-intrinsic-size virtualise with IntersectionObserver
Image without dimensions CLS width/height attrs or aspect-ratio wrap in aspect-ratio div
Ad slot reflow CLS reserve fixed min-height aspect-ratio placeholder
Banner injected on scroll CLS inject BELOW fold tie to user gesture (auto-excluded 500 ms)
Slow next-page navigation LCP on next page Speculation Rules prerender with moderate fallback to prefetch for non-Chromium

Cross-references

  • [[frontend-perf-animation-gpu-containment]] : compositor-only animations (transform, opacity, filter), CSS containment, GPU layer promotion.
  • [[frontend-syntax-js-es2024-ts-dom]] : scheduler.yield() API surface, feature detection, INP-aware patterns.
  • [[frontend-impl-typography-system]] : webfont loading strategy, fallback metric matching.
  • [[frontend-errors-animation-jank]] : symptom-to-cause map for jank-class errors.

Reference Links

  • references/methods.md : full PerformanceObserver entry surface, LongAnimationFrame property list, Speculation Rules JSON shape, fetchpriority spec, yield ladder.
  • references/examples.md : renderable index.html with LCP-optimised image, Speculation Rules block, click handler that yields, plus standalone snippets.
  • references/anti-patterns.md : twelve anti-patterns with metric mapping, symptom, root cause, fix.

Authoritative sources