**TL;DR.** INP on PDP is fixed by shipping less JavaScript and making interactions optimistic. Use React Server Components for static parts. Use `useOptimistic` for variant swap and add-to-cart. Use `startTransition` for non-urgent updates. Debounce search with `AbortController`. Hoist regex and avoid spread in event handlers. Target: < 200ms p75, achievable < 100ms.

## What INP measures

Interaction to Next Paint (INP) measures the longest interaction-to-paint time on a page during the user's visit. An "interaction" is a click, tap, or keypress that triggers JavaScript.

INP is calculated as the **worst case** (excluding rare outliers), not the average. One slow filter click can ruin your INP score even if 99 other interactions were fast.

| INP value      | Verdict           |
| -------------- | ----------------- |
| ≤ 200ms        | Good              |
| 200ms – 500ms  | Needs improvement |
| > 500ms        | Poor              |

## Step 1: Profile, don't guess

Use Chrome DevTools Performance tab to record a PDP interaction session. Look for:

- Long tasks (yellow bars > 50ms) during interactions.
- Forced reflows (purple bars).
- Heavy `attribution` for INP entries.

Or use the Long Animation Frames (LoAF) API in your RUM:

```ts
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 200) {
      console.log('Long frame:', entry);
    }
  }
});
observer.observe({ type: 'long-animation-frame', buffered: true });
```

## Step 2: Defer non-critical JS

The biggest INP wins on most PDPs come from removing or deferring scripts you didn't need to load eagerly:

| Script type             | Recommended strategy           |
| ----------------------- | ------------------------------ |
| Analytics (GA4, Plausible) | `afterInteractive`            |
| Marketing pixels         | `afterInteractive` or `lazyOnload` |
| Chat widget              | `lazyOnload` + IntersectionObserver |
| A/B test SDK              | Inline blocking (rare) or `beforeInteractive` |
| Recommendation engine    | Server Component or lazy IO    |
| Customer review widget   | IntersectionObserver lazy load |

In Next.js:

```tsx
import Script from 'next/script';

<Script src="/marketing-pixel.js" strategy="afterInteractive" />
<Script src="/chat-widget.js" strategy="lazyOnload" />
```

## Step 3: Convert to Server Components

PDP structure typically:

```tsx
// PDPPage (Server Component)
import { ProductGallery } from './product-gallery';      // Server
import { ProductInfo } from './product-info';            // Server
import { AddToCartButton } from './add-to-cart-button';  // Client (interactive)
import { VariantSelector } from './variant-selector';    // Client (interactive)
import { RelatedProducts } from './related-products';    // Server
import { ReviewSummary } from './review-summary';        // Server

export default async function PDP({ params }) {
  const product = await getProduct(params.slug);
  return (
    <article>
      <ProductGallery images={product.images} />
      <ProductInfo product={product} />
      <VariantSelector variants={product.variants} />
      <AddToCartButton productId={product.id} />
      <ReviewSummary reviews={product.reviews} />
      <RelatedProducts productId={product.id} />
    </article>
  );
}
```

Only `AddToCartButton` and `VariantSelector` are Client Components. Everything else stays on the server. Bundle size drops 60–80% versus a fully-client PDP.

## Step 4: Optimistic variant swap

The naive variant click flow:

1. User clicks variant.
2. POST to `/api/variant/select` with new variant ID.
3. Server returns updated price/SKU/image.
4. Re-render with new state.

Total: 200–500ms perceived latency. INP score: bad.

The optimistic flow:

```tsx
'use client';
import { useOptimistic, startTransition } from 'react';

export function VariantSelector({ variants, initialSelected }) {
  const [selected, setOptimistic] = useOptimistic(initialSelected);

  function handleSelect(variant) {
    setOptimistic(variant);
    startTransition(async () => {
      await selectVariantServerAction(variant.id);
    });
  }

  return (
    <div>
      {variants.map((v) => (
        <button
          key={v.id}
          onClick={() => handleSelect(v)}
          aria-pressed={selected.id === v.id}
        >
          {v.label}
        </button>
      ))}
    </div>
  );
}
```

INP drops to < 100ms because the optimistic state updates synchronously and the server work runs in a transition.

## Step 5: Gallery and thumbnail

The product gallery is a common INP killer if it eagerly loads all images. Pattern:

```tsx
'use client';
import { startTransition, useState } from 'react';
import Image from 'next/image';

export function ProductGallery({ images }) {
  const [active, setActive] = useState(images[0]);

  function handleThumbClick(image) {
    startTransition(() => setActive(image));
  }

  return (
    <div>
      <Image
        src={active.src}
        width={800}
        height={800}
        priority
        alt={active.alt}
      />
      <div>
        {images.map((img) => (
          <button
            key={img.id}
            onClick={() => handleThumbClick(img)}
            onMouseEnter={() => preloadImage(img.src)}
          >
            <Image src={img.thumbSrc} width={80} height={80} alt="" />
          </button>
        ))}
      </div>
    </div>
  );
}
```

Preload the hovered image (`onMouseEnter`) so the click is instant.

## Step 6: Debounced search

```tsx
'use client';
import { useEffect, useState } from 'react';

const MAX_SUGGESTIONS = 8;
const DEBOUNCE_MS = 200;

export function SearchAutocomplete() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    const controller = new AbortController();
    const timeout = setTimeout(async () => {
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        setResults(data.slice(0, MAX_SUGGESTIONS));
      } catch (err) {
        if (err.name !== 'AbortError') throw err;
      }
    }, DEBOUNCE_MS);
    return () => {
      clearTimeout(timeout);
      controller.abort();
    };
  }, [query]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {results.map((r) => (
          <li key={r.id}>{r.name}</li>
        ))}
      </ul>
    </>
  );
}
```

## Step 7: Hoist regex and avoid spread

Bad:

```tsx
function handleClick(text) {
  if (/[<>]/.test(text)) { ... }   // regex created on every click
  setItems(prev => [...prev, ...newItems]); // spread in accumulator
}
```

Good:

```tsx
const TAG_REGEX = /[<>]/;  // hoisted to module scope

function handleClick(text) {
  if (TAG_REGEX.test(text)) { ... }
  setItems(prev => prev.concat(newItems));
}
```

## Step 8: Lazy-load below-the-fold

Recommendations, reviews, related products: put them below the fold and lazy-load with `dynamic` + `ssr: false` for purely client widgets, or load on intersection:

```tsx
import dynamic from 'next/dynamic';

const HeavyReviewWidget = dynamic(() => import('./heavy-review-widget'), {
  ssr: false,
  loading: () => <ReviewSkeleton />,
});
```

For server-rendered sections, wrap in `<Suspense>` with PPR enabled so they stream after the static shell.

## Measuring success

After deploying fixes, INP improvements show in:

1. **Chrome DevTools** under Performance → Web Vitals overlay (synthetic, immediate).
2. **Your own RUM** with `web-vitals` library (real-user, near-real-time).
3. **Search Console → Core Web Vitals** (real-user, p75 over 28 days, 2–4 weeks lag).

A typical PDP that started at 350ms p75 INP and follows this guide reaches 80–120ms p75 INP within 2–4 weeks of deployment.

## How Ordiko ships INP-good PDPs by default

- Static parts of PDP are RSC (no client JS shipped).
- Variant selector uses `useOptimistic` + Server Actions.
- Gallery uses `startTransition` + image preload on hover.
- Search debounced at 200ms with `AbortController`.
- Cart drawer renders last-known count optimistically.

Default p75 INP: 80–160ms.

## FAQ

**Why is INP so much harder than LCP?**
LCP is mostly a CDN and image-optimization problem with well-understood fixes. INP is JavaScript-execution-time on user interaction; each click can have a different bottleneck. Fixing INP requires component-level discipline across every interactive element on the page.

**Does React Server Components fix INP automatically?**
Largely yes. RSC reduces the JS bundle shipped to the browser, which reduces main-thread work, which improves INP. But interactive parts of the page still need client JS — RSC alone won't fix a poorly-written client component that creates regex in onClick.

**What's the single biggest INP killer on PDPs?**
Heavy client-side rendering of recommendation widgets ('Frequently bought together', 'You might also like') that ship 50–200KB of JS and hydrate aggressively on first interaction. Move these to Server Components or lazy-load behind IntersectionObserver.

**How fast can a well-optimized PDP get on INP?**
Real-world p75 INP under 100ms is achievable. Ordiko PDPs default to 80–160ms p75 INP without manual optimization. Custom themes that follow the patterns in this guide can get below 100ms consistently.
