When working with JavaScript, it's important to prioritize urgent tasks, such as updating the UI, over non-critical tasks. Since the browser can only handle one task at a time (single threaded), it's crucial to identify and focus on the most critical tasks, especially within event handlers.

INP 100 ms

Blocking the browsers main thread

🧊

The main thread is blocked,
when the cube stops rotating.

INP 1100 ms

Blocking the browsers main thread

🧊

The main thread is blocked,
when the cube stops rotating.

#Prioritzie UI Updates

When an event handler is called, the browser should prioritize updating the UI over other tasks. These updates might should also be performed incrementaly, so the user can see the progress along the way.

There are a few ways to defer non-critical work and improve input latency. Below you find multiple methods to enable the browser to separate work to be done and enabling the browser to focus on UI updates while processing updates in smaller chunks.

Check out "Optimize Long Tasks" by web.dev to read about tasks and find more information.

Keep in mind that it is not enough to simply postpone tasks to a later time. You should also break the work into smaller chunks to allow the browser to update the UI in between.

Check out these examples to learn more about the impact of different actions on the INP: Cypress Tests to Verify INP Assumptions

#useTransition()

By default, all state updates in React are urgent (high priority). With useTransition(), you can defer state updates with a lower priority, which can help improve input latency while not blocking the UI.

Check out the React documentation on useTransition() for more information.

export const useCheckboxFilterListItemController = ({ filterProducts }) => {
  const [checked, setIsChecked] = useState();
  const [isFiltering, startTransition] = useTransition();

  function onChange() {
    // Update the UI immediately
    setIsChecked(!selected);

    // Defer the filtering and state update to a separate transition task
    startTransition(() => {
      filterProducts();
    });
  }

  return {
    checked,
    onChange,
    // Leverage the isPending state e.g. to show a loading indication
    isFiltering,
  };
};

In the snippet above, if there were no difference in priority, both state changes would occur almost simultaneously. React would batch them and update them together. This is a smart move because the component is rendering when the state update takes place, and too many renders will make the application experience bad. On the other hand it would cause heavily increased input latency.

#useDeferredValue()

The useDeferredValue() hook lets you defer updating a part of the UI.

A common use case is when you have a large list of items you want to filter or sort. You can use useDeferredValue() to defer updating the list until the user has finished interacting with the filter or sort controls.

import { Suspense } from "react";
import { SearchResults } from "../searchResults.tsx";

export const FilterExample = () => {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);

  // The <input /> will update immediately, but the search results will update deferred
  return (
    <>
      <label>
        Search products:
        <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <SearchResults query={deferredQuery} />
      </Suspense>
    </>
  );
};

#onIdle()

Use this function to defer non-urgent work to a separate task. This helper is a combination of requestIdleCallback() and setTimeout() as a fallback.

requestAnimationFrame() is not suitable for deferring tasks as it is called before the next repaint. A single call of setTimeout()is not sufficient enough as it does not guarantee that the task will be executed after the browser has had a chance to update the UI.

Do

A click handler should consider UI updates to be the most important and allow the browser to prioritize them.

import { onIdle } from "@blocks/lazy";
const [productSpec] = useState("personalized-detail-page");

function showMoreClickHandler() {
  setIsExpanded(true);

  const trackingData = {
    myFeature: "example",
    productSpec,
  };
  // Defer the tracking call to a separate task
  onIdle(() => {
    sendCustomAnalytics(trackingData);
  });
}

Don't

Avoid performing asyncronous work in the event handler as they can block the UI heavily and increase input latency.

async function showMoreClickHandler() {
  const productSpec = await fetchProductSpecData();

  const trackingData = {
    myFeature: "example",
    productSpec,
  };

  setIsExpanded(true);
  sendCustomAnalytics(trackingData);
}

#Tracking and analytics

Tracking and analytics calls are typical examples of non-critical work; while they may be crucial for the business, they are not essential for the user experience. That's why they should be deferred.

In various cases, tracking calls can lead to other synchronous work which is performed by the tracking library.

If this is the case, make sure you have all the necessary data for the tracking call before deferring or scheduling the task. This ensures that the correct data for the event is sent. If the tracking call is time-sensitive, make sure it is not badly affected of any delays. E.g. generate and provide a tracking timestamp upfront.

There is no need to defer snowplow tracking calls manually as those are deferred by default.

Do

Prioritize UI updates. Defer only custom analytics calls. Snowplow or Google Analytics tracking calls are deferred by default.

import { onIdle } from "@blocks/lazy";
const [productSpecs] = useState("some-prepared-data");

function showMoreClickHandler() {
  updateUi();

  const trackingData = {
    myFeature: "example",
    productSpecs,
  };

  // They are already deferred with onIdle internally
  trackGAEvent(trackingData);
  trackSnowplowEvent(trackingData);

  onIdle(() => {
    trackCustomAnalytics(trackingData);
  });
}

Don't

Avoid performing heavy synchronous work in the event handler and avoid executing tracking calls directly in the event handler.

import { onIdle } from "@blocks/lazy";

async function clickHandler() {
  const trackingData = await fetchTrackingData();

  // imagine this tracking call is reading the window size
  // and does other synchronous work
  trackCustomAnalytics(trackingData);

  onIdle(() => {
    trackGAEvent(trackingData);
    trackSnowplowEvent(trackingData);
  });

  updateUi();
}

#Expensive Computations

When an event handler is called, it should not be burdened with heavy calculations. Instead, these calculations should be done before or after the event handler is executed.

Do

Use e.g. web workers to offload non-critical work to a separate thread.

const [value, setValue] = useState("");
  const filteredResults = useWorkerMemo(workerLoader, value);

  return (

    <>
      <input
        type="text"
        value={value}
        onChange={({ target }) => setValue(target.value)}
      />
      {filteredResults}
    </>
  );

Don't

Avoid performing heavy computations in the event handler as they can block the UI heavily.
const [inputValue, setInputValue] = useState("");
const [filteredResults, setFilteredResults] = useState("");

return (
  <>
    <input
      type="text"
      value={inputValue}
      onChange={({ target }) => {
        setInputValue(target.value);
        const filteredResults = filterResults(target.value);
        setFilteredResults(filteredResults);
      }}
    />
    {filteredResults}
  </>
);

#Offload to web workers

Use web workers to offload non-critical work to a separate thread. Critical work would be immediate UI updates, and non-critical work would be data processing or other heavy computations which take a long time.

Web Workers are a way to run JavaScript code in the background, separate from the main thread. This allows you to perform tasks that are not time-sensitive, such as data processing or image manipulation, without blocking the main thread.

Check out these docs for an example of how to leverage web workers to offload non-critical work.

This diagram can help you decide when to use which method to optimize performance.

web-vitals - the life of an interaction in the browser