# INP Optimization: Advanced Techniques for Sub-200ms Interactions

> Advanced strategies for achieving Good INP scores including scheduler APIs, rendering optimization, framework-specific patterns, and real-user monitoring approaches.

Canonical page: [https://tryrankwise.com/resources/guides/inp-optimization](https://tryrankwise.com/resources/guides/inp-optimization). Updated 2026-03-30. Published by Rankwise (tryrankwise.com).

Getting INP under 200ms on simple pages is straightforward: defer scripts, break up long tasks, optimize handlers. But complex applications with rich interactivity, heavy state management, and dozens of third-party integrations require advanced techniques.

This guide covers optimization strategies for teams that have already addressed the basics and need to push INP from "Needs Improvement" to "Good."

---

## The Scheduler API: Prioritized Task Management

The Scheduler API (`scheduler.postTask()` and `scheduler.yield()`) gives you fine-grained control over task priority on the main thread.

### Priority Levels

| Priority        | Use Case                         | Example                       |
| --------------- | -------------------------------- | ----------------------------- |
| `user-blocking` | Direct response to user action   | Updating clicked button state |
| `user-visible`  | Important but not blocking input | Loading content below fold    |
| `background`    | Non-urgent work                  | Analytics, prefetching        |

### Yielding During Long Tasks

```javascript
async function processData(items) {
  const results = []

  for (const item of items) {
    results.push(transform(item))

    // Yield with user-visible priority so user interactions take precedence
    await scheduler.yield()
  }

  return results
}
```

### Scheduling Non-Urgent Work

```javascript
// Analytics and tracking can wait
scheduler.postTask(
  () => {
    trackPageView(pageData)
  },
  { priority: "background" }
)

// UI updates from user action are urgent
scheduler.postTask(
  () => {
    updateSearchResults(query)
  },
  { priority: "user-blocking" }
)
```

### Fallback for Unsupported Browsers

```javascript
const yieldToMain = globalThis.scheduler?.yield
  ? () => scheduler.yield()
  : () => new Promise(resolve => setTimeout(resolve, 0))
```

---

## Rendering Optimization

### CSS Containment

The `contain` property tells the browser that an element's subtree is independent, limiting the scope of style recalculation and layout:

```css
/* Limit layout/paint scope for independent components */
.card {
  contain: layout style;
}

/* Full containment for off-screen sections */
.below-fold-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;
}
```

`content-visibility: auto` is particularly powerful: the browser skips rendering for off-screen content entirely, reducing layout costs when interactions trigger style recalculation.

### Avoiding Forced Reflows

Certain property reads force the browser to synchronously compute layout. In event handlers, this creates a cascade:

**Layout-triggering reads** (partial list):

- `offsetTop`, `offsetLeft`, `offsetWidth`, `offsetHeight`
- `scrollTop`, `scrollLeft`, `scrollWidth`, `scrollHeight`
- `clientTop`, `clientLeft`, `clientWidth`, `clientHeight`
- `getComputedStyle()`
- `getBoundingClientRect()`

**Pattern to avoid**:

```javascript
// Forces layout twice per loop iteration
elements.forEach(el => {
  const width = el.offsetWidth // Read → force layout
  el.style.width = width * 2 + "px" // Write → invalidate layout
})
```

**Fixed pattern**:

```javascript
// Batch reads, then batch writes
const widths = elements.map(el => el.offsetWidth)
elements.forEach((el, i) => {
  el.style.width = widths[i] * 2 + "px"
})
```

### Virtual Scrolling for Long Lists

Rendering thousands of DOM nodes degrades INP because every interaction triggers style recalculation across the entire tree. Virtual scrolling renders only visible items:

- React: `react-window` or `@tanstack/react-virtual`
- Vue: `vue-virtual-scroller`
- Vanilla: Intersection Observer + dynamic DOM insertion

Target: keep rendered DOM under 1,500 nodes for optimal interaction performance.

---

## Framework-Specific Optimization

### React

**Use `useTransition` for non-urgent updates:**

```jsx
function SearchComponent() {
  const [query, setQuery] = useState("")
  const [results, setResults] = useState([])
  const [isPending, startTransition] = useTransition()

  function handleChange(e) {
    setQuery(e.target.value) // Urgent: update input immediately

    startTransition(() => {
      setResults(filterResults(e.target.value)) // Non-urgent: can be interrupted
    })
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <ResultsList results={results} />}
    </>
  )
}
```

**Memoize expensive renders:**

```jsx
const ExpensiveList = memo(function ExpensiveList({ items }) {
  return items.map(item => <ComplexCard key={item.id} item={item} />)
})
```

**Avoid re-rendering from context:**
Split context into frequently-changing values (user input) and stable values (theme, config). Use `useSyncExternalStore` for external state to prevent unnecessary re-renders.

### Next.js

- Use Server Components for static content, since they ship zero client-side JavaScript
- Stream responses with Suspense to reduce hydration blocking
- Use `next/dynamic` with `ssr: false` for client-only interactive widgets
- Implement `useOptimistic` for instant UI feedback on mutations

### Vue

- Use `v-memo` to skip re-rendering static list items
- Prefer `shallowRef` for large objects that change identity but not content
- Use `defineAsyncComponent` for below-fold interactive sections

---

## Third-Party Script Isolation

### Web Worker Proxy (Partytown)

Move third-party scripts entirely off the main thread using Partytown or a similar library:

```html
<script type="text/partytown">
  // This analytics script runs in a Web Worker
  (function() {
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', 'GA_ID');
  })();
</script>
```

### Interaction-Triggered Loading

Load scripts only when a user first interacts with the relevant feature:

```javascript
const chatButton = document.getElementById("chat-trigger")

chatButton.addEventListener(
  "click",
  async function initChat() {
    chatButton.textContent = "Loading..."

    const { initWidget } = await import("./chat-widget.js")
    initWidget()

    chatButton.removeEventListener("click", initChat)
  },
  { once: true }
)
```

### Resource Hints for Anticipated Scripts

If a script will load on interaction, use `preconnect` to establish the connection early:

```html
<link rel="preconnect" href="https://chat-widget.example.com" />
```

---

## Real-User Monitoring Strategy

### Attribution for Debugging

The `web-vitals` library provides attribution data that identifies the exact element and handler causing poor INP:

```javascript
import { onINP } from "web-vitals/attribution"

onINP(metric => {
  if (metric.rating !== "good") {
    console.log("Slow interaction:", {
      element: metric.attribution.interactionTarget,
      type: metric.attribution.interactionType,
      inputDelay: metric.attribution.inputDelay,
      processingDuration: metric.attribution.processingDuration,
      presentationDelay: metric.attribution.presentationDelay,
      longestScript: metric.attribution.longAnimationFrameEntries?.[0]
    })
  }
})
```

### Page-Level Segmentation

Aggregate INP by page template to identify which page types need work:

| Page Type     | p75 INP | Status            |
| ------------- | ------- | ----------------- |
| Homepage      | 145ms   | Good              |
| Product pages | 312ms   | Needs improvement |
| Blog posts    | 89ms    | Good              |
| Dashboard     | 487ms   | Poor              |

### Regression Detection

Set up alerting for INP regressions in your CI/CD pipeline:

- Run Lighthouse in CI with TBT budget assertions
- Monitor CrUX weekly for origin-level INP changes
- Alert when p75 INP crosses 200ms threshold

---

## FAQ

**Is `scheduler.yield()` supported in all browsers?**
As of 2026, it's supported in Chromium browsers. For Safari and Firefox, use the `setTimeout(fn, 0)` fallback. The behavior is similar for INP purposes since CrUX only measures Chrome users.

**How much does virtual scrolling improve INP?**
For pages with 1,000+ list items, virtual scrolling typically reduces INP by 40-60% by keeping rendered DOM under 100 items regardless of total list size.

**Should I use Partytown for all third-party scripts?**
No. Partytown proxies DOM access through a synchronous communication channel, which can break scripts that rely on immediate DOM reads. Test each script individually. It works well for analytics and tracking, less reliably for interactive widgets.

**What INP target should I set for a complex SPA?**
Aim for under 200ms (Good threshold). For rich interactive applications like dashboards or editors, 150-200ms is realistic. Under 100ms typically requires significant architectural investment.
