Steve Kinney

INP Optimization & Long Tasks

Your React app looks fast. The page loads quickly, the content appears instantly. Then a user clicks a button and… nothing. For 200 milliseconds, the UI is frozen. The click eventually processes, but that delay—that moment of uncertainty—just cost you a user’s trust. Welcome to the world of INP (Interaction to Next Paint), where every millisecond of delay is a broken promise.

INP measures how quickly your app responds to user interactions throughout its entire lifecycle. Not just the first click, but every click, tap, and keypress. And here’s the harsh reality: most React apps fail INP not because they’re slow, but because they’re doing too much work on the main thread at the wrong time.

Let’s fix that. Let’s break apart those long tasks, leverage modern scheduling APIs, and build React apps that respond instantly to every interaction.

Understanding INP and Long Tasks

INP measures the latency of all interactions during a page visit:

interface INPBreakdown {
  inputDelay: number; // Time until event handler starts
  processingTime: number; // Time to run event handlers
  presentationDelay: number; // Time to paint next frame
  totalINP: number; // Sum of all three
}

// Good INP: < 200ms
// Needs improvement: 200-500ms
// Poor INP: > 500ms

// Long task = any script execution > 50ms
interface LongTask {
  duration: number;
  startTime: number;
  attribution: TaskAttribution[];
}

Breaking Up Long Tasks

The Problem: Monolithic Event Handlers

// ❌ Long task - blocks the main thread
const BadSearchComponent: React.FC = () => {
  const [results, setResults] = useState<Item[]>([]);

  const handleSearch = (query: string) => {
    // This blocks the main thread for 200ms+
    const filtered = hugeDataset.filter(item => {
      // Complex filtering logic
      return item.title.toLowerCase().includes(query) &&
             item.description.toLowerCase().includes(query) &&
             calculateRelevance(item, query) > 0.5;
    });

    const sorted = filtered.sort((a, b) => {
      return calculateScore(b, query) - calculateScore(a, query);
    });

    const highlighted = sorted.map(item => ({
      ...item,
      highlightedTitle: highlightText(item.title, query),
      highlightedDesc: highlightText(item.description, query)
    }));

    setResults(highlighted);
  };

  return <SearchInput onChange={handleSearch} />;
};

Solution 1: Yielding to the Main Thread

// ✅ Yield control back to the browser
const GoodSearchComponent: React.FC = () => {
  const [results, setResults] = useState<Item[]>([]);
  const [isSearching, setIsSearching] = useState(false);

  const handleSearch = async (query: string) => {
    setIsSearching(true);

    // Break work into chunks
    const chunkSize = 100;
    const filtered: Item[] = [];

    for (let i = 0; i < hugeDataset.length; i += chunkSize) {
      const chunk = hugeDataset.slice(i, i + chunkSize);

      // Process chunk
      const chunkFiltered = chunk.filter(item => {
        return item.title.toLowerCase().includes(query) &&
               item.description.toLowerCase().includes(query);
      });

      filtered.push(...chunkFiltered);

      // Yield to main thread
      await yieldToMain();
    }

    // Sort in chunks
    const sorted = await chunkSort(filtered, (a, b) => {
      return calculateScore(b, query) - calculateScore(a, query);
    });

    setResults(sorted);
    setIsSearching(false);
  };

  return (
    <>
      <SearchInput onChange={handleSearch} />
      {isSearching && <SearchingIndicator />}
    </>
  );
};

// Utility to yield control
const yieldToMain = (): Promise<void> => {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
};

// Sort in chunks
async function chunkSort<T>(
  array: T[],
  compareFn: (a: T, b: T) => number
): Promise<T[]> {
  // Use merge sort with yielding
  if (array.length <= 1) return array;

  const middle = Math.floor(array.length / 2);
  const left = array.slice(0, middle);
  const right = array.slice(middle);

  await yieldToMain();

  const sortedLeft = await chunkSort(left, compareFn);
  const sortedRight = await chunkSort(right, compareFn);

  return merge(sortedLeft, sortedRight, compareFn);
}

Solution 2: Using requestIdleCallback

const useIdleProcessor = <T, R>(
  processor: (items: T[]) => R[],
  options?: IdleRequestOptions
) => {
  const [result, setResult] = useState<R[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);
  const taskQueueRef = useRef<T[]>([]);

  const process = useCallback((items: T[]) => {
    taskQueueRef.current = [...items];
    setIsProcessing(true);

    const processQueue = (deadline: IdleDeadline) => {
      const results: R[] = [];

      // Process while we have time
      while (
        taskQueueRef.current.length > 0 &&
        (deadline.timeRemaining() > 0 || deadline.didTimeout)
      ) {
        const batch = taskQueueRef.current.splice(0, 10);
        results.push(...processor(batch));
      }

      setResult(prev => [...prev, ...results]);

      // Continue if more work
      if (taskQueueRef.current.length > 0) {
        requestIdleCallback(processQueue, options);
      } else {
        setIsProcessing(false);
      }
    };

    requestIdleCallback(processQueue, options);
  }, [processor, options]);

  return { process, result, isProcessing };
};

// Usage
const DataProcessor: React.FC = () => {
  const { process, result, isProcessing } = useIdleProcessor(
    (items: Data[]) => items.map(transform),
    { timeout: 2000 }
  );

  return (
    <div>
      <button onClick={() => process(largeDataset)}>
        Process Data
      </button>
      {isProcessing && <ProcessingIndicator />}
      <Results data={result} />
    </div>
  );
};

Using the Scheduler API

PostTask API for Priority Scheduling

// Modern scheduler.postTask API
class TaskScheduler {
  private tasks = new Map<string, AbortController>();

  async scheduleTask<T>(
    id: string,
    task: () => T | Promise<T>,
    priority: 'user-blocking' | 'user-visible' | 'background' = 'user-visible'
  ): Promise<T> {
    // Cancel previous task with same ID
    this.cancelTask(id);

    const controller = new AbortController();
    this.tasks.set(id, controller);

    if ('scheduler' in window && 'postTask' in scheduler) {
      try {
        return await scheduler.postTask(task, {
          priority,
          signal: controller.signal
        });
      } catch (error) {
        if (error.name === 'AbortError') {
          console.log(`Task ${id} was cancelled`);
        }
        throw error;
      }
    } else {
      // Fallback for browsers without postTask
      return this.fallbackSchedule(task, priority);
    }
  }

  private async fallbackSchedule<T>(
    task: () => T | Promise<T>,
    priority: string
  ): Promise<T> {
    const delay = priority === 'user-blocking' ? 0 :
                  priority === 'user-visible' ? 50 : 100;

    await new Promise(resolve => setTimeout(resolve, delay));
    return task();
  }

  cancelTask(id: string) {
    const controller = this.tasks.get(id);
    if (controller) {
      controller.abort();
      this.tasks.delete(id);
    }
  }

  cancelAll() {
    this.tasks.forEach(controller => controller.abort());
    this.tasks.clear();
  }
}

// React hook for task scheduling
const useTaskScheduler = () => {
  const schedulerRef = useRef(new TaskScheduler());

  useEffect(() => {
    return () => {
      schedulerRef.current.cancelAll();
    };
  }, []);

  return schedulerRef.current;
};

// Usage in component
const InteractiveComponent: React.FC = () => {
  const scheduler = useTaskScheduler();
  const [data, setData] = useState<ProcessedData | null>(null);

  const handleClick = async () => {
    // High priority - user initiated
    const result = await scheduler.scheduleTask(
      'process-click',
      () => processUserInput(),
      'user-blocking'
    );

    setData(result);

    // Low priority - analytics
    scheduler.scheduleTask(
      'analytics',
      () => sendAnalytics(result),
      'background'
    );
  };

  return <button onClick={handleClick}>Process</button>;
};

Optimizing React Rendering for INP

Concurrent Features for Better INP

// ✅ Use transitions for non-urgent updates
const SearchWithTransition: React.FC = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value: string) => {
    // Urgent - update input immediately
    setQuery(value);

    // Non-urgent - search can be interrupted
    startTransition(() => {
      const filtered = performExpensiveSearch(value);
      setResults(filtered);
    });
  };

  return (
    <>
      <input
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        className={isPending ? 'searching' : ''}
      />
      <SearchResults results={results} />
    </>
  );
};

// ✅ Use deferred values for expensive renders
const ExpensiveList: React.FC<{ items: Item[] }> = ({ items }) => {
  const deferredItems = useDeferredValue(items);
  const isStale = items !== deferredItems;

  return (
    <div style={{ opacity: isStale ? 0.5 : 1 }}>
      {deferredItems.map(item => (
        <ExpensiveItem key={item.id} data={item} />
      ))}
    </div>
  );
};

Event Delegation for Better Performance

// ❌ Many event listeners
const BadList: React.FC<{ items: Item[] }> = ({ items }) => {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => handleClick(item)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
};

// ✅ Single delegated listener
const GoodList: React.FC<{ items: Item[] }> = ({ items }) => {
  const handleListClick = (e: React.MouseEvent) => {
    const target = e.target as HTMLElement;
    const li = target.closest('li');

    if (li) {
      const id = li.dataset.itemId;
      const item = items.find(i => i.id === id);
      if (item) handleClick(item);
    }
  };

  return (
    <ul onClick={handleListClick}>
      {items.map(item => (
        <li key={item.id} data-item-id={item.id}>
          {item.name}
        </li>
      ))}
    </ul>
  );
};

Avoiding Layout Thrashing

Batch DOM Reads and Writes

// ❌ Layout thrashing - forces multiple reflows
const BadMeasurement: React.FC = () => {
  const measureElements = () => {
    const elements = document.querySelectorAll('.item');

    elements.forEach(el => {
      const height = el.clientHeight; // Read
      el.style.height = `${height * 2}px`; // Write - forces reflow
      const width = el.clientWidth; // Read - forces reflow again
      el.style.width = `${width * 2}px`; // Write
    });
  };

  return <button onClick={measureElements}>Measure</button>;
};

// ✅ Batched reads and writes
const GoodMeasurement: React.FC = () => {
  const measureElements = () => {
    const elements = document.querySelectorAll('.item');
    const measurements: Array<{ el: Element; height: number; width: number }> = [];

    // Batch all reads
    elements.forEach(el => {
      measurements.push({
        el,
        height: el.clientHeight,
        width: el.clientWidth
      });
    });

    // Batch all writes
    measurements.forEach(({ el, height, width }) => {
      (el as HTMLElement).style.height = `${height * 2}px`;
      (el as HTMLElement).style.width = `${width * 2}px`;
    });
  };

  return <button onClick={measureElements}>Measure</button>;
};

// Using requestAnimationFrame for DOM updates
const AnimatedComponent: React.FC = () => {
  const updatePositions = (elements: HTMLElement[]) => {
    // Read phase
    const positions = elements.map(el => ({
      el,
      current: el.getBoundingClientRect()
    }));

    // Write phase in next frame
    requestAnimationFrame(() => {
      positions.forEach(({ el, current }) => {
        el.style.transform = `translateY(${current.top + 100}px)`;
      });
    });
  };

  return <div>Animated content</div>;
};

Input Responsiveness Optimization

Debouncing vs Throttling for Inputs

// Custom hook for optimized input handling
const useOptimizedInput = (
  callback: (value: string) => void,
  delay: number = 300
) => {
  const [value, setValue] = useState('');
  const [isPending, setIsPending] = useState(false);
  const callbackRef = useRef(callback);
  const timeoutRef = useRef<NodeJS.Timeout>();

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value;

    // Update input immediately (good INP)
    setValue(newValue);
    setIsPending(true);

    // Clear previous timeout
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    // Debounce the expensive operation
    timeoutRef.current = setTimeout(() => {
      // Use scheduler for non-blocking execution
      if ('scheduler' in window && 'postTask' in scheduler) {
        scheduler.postTask(() => {
          callbackRef.current(newValue);
          setIsPending(false);
        }, { priority: 'user-visible' });
      } else {
        callbackRef.current(newValue);
        setIsPending(false);
      }
    }, delay);
  }, [delay]);

  return { value, handleChange, isPending };
};

// Usage
const SearchInput: React.FC = () => {
  const { value, handleChange, isPending } = useOptimizedInput(
    (query) => performSearch(query),
    300
  );

  return (
    <div>
      <input
        value={value}
        onChange={handleChange}
        placeholder="Search..."
      />
      {isPending && <span>Searching...</span>}
    </div>
  );
};

Optimizing Form Validation

class ValidationScheduler {
  private validationQueue = new Map<string, () => Promise<ValidationResult>>();
  private results = new Map<string, ValidationResult>();

  async scheduleValidation(fieldName: string, validator: () => Promise<ValidationResult>) {
    this.validationQueue.set(fieldName, validator);

    // Use idle time for validation
    return new Promise<ValidationResult>((resolve) => {
      requestIdleCallback(async (deadline) => {
        if (deadline.timeRemaining() > 10) {
          const result = await validator();
          this.results.set(fieldName, result);
          this.validationQueue.delete(fieldName);
          resolve(result);
        } else {
          // Reschedule if not enough time
          this.scheduleValidation(fieldName, validator).then(resolve);
        }
      });
    });
  }

  getResult(fieldName: string): ValidationResult | undefined {
    return this.results.get(fieldName);
  }
}

interface ValidationResult {
  valid: boolean;
  errors?: string[];
}

const useFormValidation = () => {
  const schedulerRef = useRef(new ValidationScheduler());
  const [errors, setErrors] = useState<Record<string, string[]>>({});

  const validateField = useCallback(
    async (name: string, value: string, rules: ValidationRule[]) => {
      const result = await schedulerRef.current.scheduleValidation(name, async () => {
        // Run validation rules
        const errors: string[] = [];
        for (const rule of rules) {
          if (!rule.test(value)) {
            errors.push(rule.message);
          }
        }
        return { valid: errors.length === 0, errors };
      });

      setErrors((prev) => ({
        ...prev,
        [name]: result.errors || [],
      }));
    },
    [],
  );

  return { validateField, errors };
};

interface ValidationRule {
  test: (value: string) => boolean;
  message: string;
}

Monitoring INP in Production

class INPMonitor {
  private observer: PerformanceObserver | null = null;
  private interactions: InteractionEntry[] = [];
  private inp = 0;

  startMonitoring() {
    if (!('PerformanceObserver' in window)) return;

    this.observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'event' || entry.entryType === 'first-input') {
          this.processInteraction(entry as InteractionEntry);
        }
      }
    });

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

  private processInteraction(entry: InteractionEntry) {
    // Only consider interactions with duration
    if (entry.duration === 0) return;

    this.interactions.push(entry);

    // Calculate INP (98th percentile of interactions)
    this.calculateINP();

    // Report slow interactions
    if (entry.duration > 200) {
      this.reportSlowInteraction(entry);
    }
  }

  private calculateINP() {
    if (this.interactions.length === 0) return;

    const sorted = [...this.interactions].sort((a, b) => b.duration - a.duration);
    const index = Math.floor(sorted.length * 0.98);
    this.inp = sorted[index].duration;
  }

  private reportSlowInteraction(entry: InteractionEntry) {
    const report = {
      type: entry.name,
      duration: entry.duration,
      startTime: entry.startTime,
      target: entry.target,
      inp: this.inp,
      url: window.location.href,
      timestamp: Date.now(),
    };

    // Send to analytics
    navigator.sendBeacon('/api/metrics/inp', JSON.stringify(report));
  }

  getINP(): number {
    return this.inp;
  }

  getInteractions(): InteractionEntry[] {
    return this.interactions;
  }
}

interface InteractionEntry extends PerformanceEntry {
  duration: number;
  interactionId?: number;
  target?: Node;
}

// React hook for INP monitoring
const useINPMonitoring = () => {
  const monitorRef = useRef<INPMonitor | null>(null);
  const [inp, setINP] = useState(0);

  useEffect(() => {
    monitorRef.current = new INPMonitor();
    monitorRef.current.startMonitoring();

    const interval = setInterval(() => {
      if (monitorRef.current) {
        setINP(monitorRef.current.getINP());
      }
    }, 5000);

    return () => {
      clearInterval(interval);
    };
  }, []);

  return { inp };
};

Best Practices Checklist

Break up long tasks

  • Yield to main thread every 50ms
  • Use requestIdleCallback for non-urgent work
  • Implement task scheduling with priorities

Optimize event handlers

  • Debounce expensive operations
  • Use event delegation
  • Process in chunks with yielding

Avoid layout thrashing

  • Batch DOM reads and writes
  • Use requestAnimationFrame for updates
  • Cache layout measurements

Use React concurrent features

  • Mark non-urgent updates with transitions
  • Defer expensive renders
  • Use Suspense for data fetching

Monitor INP

  • Track all user interactions
  • Identify slow interactions
  • Report metrics to analytics

Last modified on .