Front-end Engineering Lab

Interaction to Next Paint (INP)

Optimize responsiveness with the new Core Web Vital metric replacing FID

Interaction to Next Paint (INP)

INP is Google's newest Core Web Vital metric, replacing FID (First Input Delay) in March 2024. It measures overall responsiveness throughout the entire page lifecycle.

What is INP?

Interaction to Next Paint measures the latency of ALL user interactions (clicks, taps, keyboard) during the entire visit.

INP vs FID

FID (Old):
- Only measures FIRST interaction
- Misses ongoing responsiveness issues
- Replaced by INP in 2024

INP (New):
- Measures ALL interactions
- Better representation of user experience
- Now a Core Web Vital

INP Scoring

Good:      < 200ms
Needs Improvement: 200-500ms
Poor:      > 500ms

Target: All interactions should respond within 200ms

What INP Measures

User clicks button

[Input Delay] ← Time until browser starts processing

[Processing Time] ← JavaScript execution

[Presentation Delay] ← Rendering updates

Visual feedback appears

INP = Input Delay + Processing Time + Presentation Delay

Measuring INP

web-vitals Library

npm install web-vitals
import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log('INP:', metric.value, 'ms');
  console.log('Rating:', metric.rating); // 'good', 'needs-improvement', 'poor'
  console.log('Attribution:', metric.attribution);
  
  // Send to analytics
  gtag('event', 'INP', {
    value: Math.round(metric.value),
    event_category: 'Web Vitals',
    event_label: metric.id,
  });
});

Chrome DevTools

1. Open DevTools
2. Performance tab
3. Record interaction
4. Look for "Interaction" events
5. Check total duration

Performance Observer

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry: any) => {
    if (entry.interactionId) {
      const inp = entry.duration;
      console.log('Interaction:', {
        type: entry.name,
        duration: inp,
        target: entry.target,
        startTime: entry.startTime,
      });
      
      if (inp > 200) {
        console.warn('Slow interaction!', inp, 'ms');
      }
    }
  });
});

observer.observe({ type: 'event', buffered: true });

Common INP Issues

1. Long Tasks (> 50ms)

// Monitor long tasks
new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    if (entry.duration > 50) {
      console.warn('Long task detected:', {
        duration: entry.duration,
        startTime: entry.startTime,
      });
    }
  });
}).observe({ type: 'longtask', buffered: true });

2. Heavy Event Handlers

// ❌ BAD: Heavy synchronous work
button.addEventListener('click', () => {
  const result = heavyCalculation();  // Blocks for 500ms
  updateUI(result);
});

// ✅ GOOD: Break into chunks
button.addEventListener('click', async () => {
  showLoading();
  
  // Yield to browser
  await new Promise(resolve => setTimeout(resolve, 0));
  
  const result = await heavyCalculation();
  updateUI(result);
  hideLoading();
});

3. Forced Synchronous Layouts

// ❌ BAD: Forces layout recalculation
function updateElements() {
  elements.forEach(el => {
    const width = el.offsetWidth;  // Reads layout
    el.style.width = width + 10 + 'px';  // Writes layout
    // Forces layout recalc on every iteration!
  });
}

// ✅ GOOD: Batch reads and writes
function updateElements() {
  // Batch reads
  const widths = elements.map(el => el.offsetWidth);
  
  // Batch writes
  elements.forEach((el, i) => {
    el.style.width = widths[i] + 10 + 'px';
  });
}

4. Third-Party Scripts

// ❌ BAD: Third-party blocks interactions
<script src="https://heavy-script.com/widget.js"></script>

// ✅ GOOD: Load after interactions
useEffect(() => {
  // Load after first interaction
  const loadWidget = () => {
    const script = document.createElement('script');
    script.src = 'https://heavy-script.com/widget.js';
    script.async = true;
    document.body.appendChild(script);
    
    // Remove listeners after first load
    document.removeEventListener('click', loadWidget);
    document.removeEventListener('keydown', loadWidget);
  };
  
  document.addEventListener('click', loadWidget, { once: true });
  document.addEventListener('keydown', loadWidget, { once: true });
}, []);

Optimization Strategies

1. Break Up Long Tasks

// Break work into smaller chunks
async function processLargeDataset(data: any[]) {
  const chunkSize = 100;
  
  for (let i = 0; i < data.length; i += chunkSize) {
    const chunk = data.slice(i, i + chunkSize);
    
    // Process chunk
    chunk.forEach(item => processItem(item));
    
    // Yield to browser every 100 items
    if (i + chunkSize < data.length) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

2. Use Scheduler API (Experimental)

// Prioritize user interactions
if ('scheduler' in window) {
  // High priority (user interaction)
  (window as any).scheduler.postTask(() => {
    handleUserClick();
  }, { priority: 'user-blocking' });
  
  // Low priority (analytics)
  (window as any).scheduler.postTask(() => {
    trackAnalytics();
  }, { priority: 'background' });
}

3. Debounce/Throttle

import { debounce, throttle } from 'lodash';

// Debounce: Wait for user to stop
const handleSearch = debounce((query: string) => {
  performSearch(query);
}, 300);

// Throttle: Limit frequency
const handleScroll = throttle(() => {
  updateScrollPosition();
}, 100, { leading: true, trailing: true });

// Usage
<input onChange={(e) => handleSearch(e.target.value)} />
<div onScroll={handleScroll} />

4. Web Workers

// heavy-work.worker.ts
self.addEventListener('message', (e) => {
  const result = expensiveCalculation(e.data);
  self.postMessage(result);
});

// Main thread
const worker = new Worker(new URL('./heavy-work.worker', import.meta.url));

button.addEventListener('click', () => {
  // Immediate feedback
  showLoading();
  
  // Heavy work in worker
  worker.postMessage(data);
  worker.onmessage = (e) => {
    updateUI(e.data);
    hideLoading();
  };
});

5. React: useTransition

import { useState, useTransition } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value: string) => {
    // Immediate update (high priority)
    setQuery(value);
    
    // Defer expensive search (low priority)
    startTransition(() => {
      const searchResults = expensiveSearch(value);
      setResults(searchResults);
    });
  };

  return (
    <>
      <input
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
      />
      {isPending ? <Spinner /> : <Results data={results} />}
    </>
  );
}

6. React: useDeferredValue

import { useState, useDeferredValue, useMemo } from 'react';

function FilteredList({ items }: Props) {
  const [filter, setFilter] = useState('');
  
  // Defer filtering (low priority)
  const deferredFilter = useDeferredValue(filter);
  
  // Expensive filtering happens with deferred value
  const filteredItems = useMemo(() => {
    return items.filter(item => 
      item.name.toLowerCase().includes(deferredFilter.toLowerCase())
    );
  }, [items, deferredFilter]);

  return (
    <>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}  // Immediate
        placeholder="Filter..."
      />
      <List items={filteredItems} />  {/* Updates deferred */}
    </>
  );
}

7. requestIdleCallback

// Run non-critical work when browser is idle
button.addEventListener('click', () => {
  // Critical: immediate feedback
  updateButtonState();
  
  // Non-critical: defer to idle
  requestIdleCallback(() => {
    trackAnalytics();
    prefetchNextPage();
  }, { timeout: 2000 });
});

8. Code Splitting

import { lazy, Suspense } from 'react';

// Load heavy component on interaction
const HeavyModal = lazy(() => import('./HeavyModal'));

function App() {
  const [showModal, setShowModal] = useState(false);

  return (
    <>
      <button onClick={() => setShowModal(true)}>
        Open Modal
      </button>
      
      {showModal && (
        <Suspense fallback={<LoadingModal />}>
          <HeavyModal onClose={() => setShowModal(false)} />
        </Suspense>
      )}
    </>
  );
}

9. Optimize React Renders

import { memo, useCallback, useMemo } from 'react';

// Memoize expensive component
const ExpensiveComponent = memo(({ data }: Props) => {
  const processedData = useMemo(() => {
    return expensiveProcessing(data);
  }, [data]);

  return <div>{processedData}</div>;
});

// Memoize callbacks
function Parent() {
  const handleClick = useCallback(() => {
    // Handler logic
  }, []);

  return <ExpensiveComponent data={data} onClick={handleClick} />;
}

10. Virtual Scrolling

import { FixedSizeList } from 'react-window';

// Only render visible items
function VirtualList({ items }: { items: any[] }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          {items[index].name}
        </div>
      )}
    </FixedSizeList>
  );
}

Debugging Poor INP

1. Identify Slow Interactions

// Log all slow interactions
onINP((metric) => {
  if (metric.value > 200) {
    console.error('Slow interaction detected!', {
      inp: metric.value,
      attribution: metric.attribution,
    });
    
    // Get details
    const { eventTarget, eventType, loadState } = metric.attribution;
    console.log('Target:', eventTarget);
    console.log('Type:', eventType);
    console.log('Page state:', loadState);
  }
});

2. Profile Interaction

// Detailed profiling
button.addEventListener('click', async (e) => {
  const startTime = performance.now();
  
  console.log('Input received');
  
  await handleClick();
  
  const processingTime = performance.now() - startTime;
  console.log('Processing time:', processingTime, 'ms');
  
  requestAnimationFrame(() => {
    const totalTime = performance.now() - startTime;
    console.log('Total INP:', totalTime, 'ms');
    
    if (totalTime > 200) {
      console.warn('Interaction exceeded 200ms threshold!');
    }
  });
});

3. Chrome DevTools Performance Insights

1. Open DevTools
2. Performance Insights tab (Chrome 102+)
3. Record interaction
4. See breakdown of INP:
   - Input delay
   - Processing time
   - Presentation delay

Real-World Example

import { useState, useTransition, useDeferredValue } from 'react';

function ProductSearch() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const deferredQuery = useDeferredValue(query);
  
  // Immediate: Update input
  const handleChange = (value: string) => {
    setQuery(value);
  };
  
  // Deferred: Search results
  const results = useSearch(deferredQuery);

  return (
    <>
      <input
        value={query}
        onChange={(e) => handleChange(e.target.value)}
        placeholder="Search products..."
      />
      
      {isPending && <Spinner />}
      
      <Results data={results} />
    </>
  );
}

// Custom hook with debounce
function useSearch(query: string) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const timeout = setTimeout(async () => {
      if (query) {
        const data = await searchAPI(query);
        setResults(data);
      } else {
        setResults([]);
      }
    }, 300);
    
    return () => clearTimeout(timeout);
  }, [query]);
  
  return results;
}

Best Practices

  1. Break Up Long Tasks: < 50ms each
  2. Use Web Workers: Heavy computation off main thread
  3. Debounce/Throttle: Limit expensive handlers
  4. Code Split: Load on interaction
  5. React Transitions: useTransition, useDeferredValue
  6. Yield to Browser: setTimeout(fn, 0) between chunks
  7. Batch DOM Operations: Read then write
  8. Virtual Scrolling: Large lists
  9. Memoization: Prevent unnecessary renders
  10. Monitor: Track INP in production

Tools

  • web-vitals: Measure INP
  • Chrome DevTools: Profile interactions
  • Lighthouse: INP score and suggestions
  • PageSpeed Insights: Field + lab data
  • Web Vitals Extension: Real-time INP

Common Pitfalls

Heavy click handlers: 500ms+ INP
Break work into chunks

Synchronous layout reads/writes: Forces reflow
Batch operations

No feedback: User waits blindly
Show loading state immediately

Everything high priority: Nothing is
Use transitions for non-critical updates

Third-parties block main thread: Poor INP
Load after interactions or use workers

INP is the most important responsiveness metric—optimize it to keep your app feeling fast and responsive!

On this page