Back to overview
How To Improve INP: View Transitions

How To Improve INP: View Transitions

— by · 
Posted on: Aug 8, 2024

In part 3 of the ‘How To Improve INP’ series, we’ll go in-depth on an optimal pattern for View Transitions (VT) to improve Interaction-to-Next-Paint (INP). Great INP is more important than ever, as it’s a metric reflecting UX and has recently been added to the Core Web Vitals (CWVs) – which are used in Google’s search ranking. The pattern is also important for VTs done with React⚛️.


This post is part of the multi-article ‘How To Improve INP’ series:


Table of contents

Open Table of contents

Finding an optimal view transitions pattern🎬

A quick reminder of the 3 stages of INP – ‘input delay’, ‘processing time’ and ‘presentation delay’:

(source: web.dev)

In the previous parts of the article series, we’ve learned how to yield and how to speed-up painting to improve our INP score. But what if the browser just decides to stop painting during view transitions, can that ever happen?

View Transitions & the freeze period

To answer the question: Yes. A surprising source of bad INP can be the new view transitions (VT) API. If a user interacts with the UI during the ‘freeze period’ of a view transitions, the reported INP can be high. During the ‘freeze period’, the whole page is literally frozen to take a snapshot of the UI, which is needed to animate between the ‘before’ and ‘after’ states. This means the browser does not paint in that period, which is why it should be kept as short as possible.

Consider the following example where the VT update callback takes a long time:

// DO NOT copy & paste this code blindly.
function MyComp() {
  const [data, setData] = useState()
  const resolveRenderPromise = useRef()

  useEffect(() => {
    // a render has happened
    resolveRenderPromise.current?.()
  })

  const startVT = () => {
    const renderPromise = new Promise((resolve) => { resolveRenderPromise.current = resolve })
    startViewTransition(async () => {
      const newData = await fetchData() // fetch
      setData(newData) // update & render
      await renderPromise // await the render
    })
  }

  return <button onClick={startVT}><SomeComp data={data} /></button>
}

With the following scenario:

  1. A user clicks the button
  2. We call startViewTransition() —> this is where the page freezes, the callback starts the React render
  3. The render might take a few seconds if <SomeComp> is slow to render
  4. A user clicks somewhere on the page
  5. useEffect runs, indicating that a render has finished, the promise returned in startViewTransition() resolves —> this is where the page unfreezes, the animation runs after

Between point c. and d., we’ll get bad INP, as the next paint is held back during the freeze period. The freeze period is as long as the slow render might take. Let’s take a closer look what’s going on.

Issues with expensive view transition callbacks 🐌

Once startViewTransition() is called, we enter the freeze period. In the code above, we start fetching data in the callback, which means we introduce some sort of delay (from milliseconds to seconds). Networks can be slow or unstable, so this is a likely cause of bad INP.

The next, not-so-obvious pitfall could be the setData(newData) call. If rendering <SomeComp> is expensive (e.g., because the rendering part is CPU heavy), we also extend the freeze period by the time it takes to render the tree.

Both are real scenarios, e.g. at Framer we’ve seen this in our RUM data. The Next.js view transition implementation is also prone to it (as of May 2024).

TODO: Fetch -> Asynchronously Render -> Transition (FART) pattern 💨

A better way to approach the view transitions is to minimize delays during the view transition freeze period. We can do that by running the following steps before starting the view transition:

  1. Fetch: Run and await all network related (data) fetches
  2. Asynchronously render in the background (non-blocking) with the fetched data (e.g. in React, by using the Activity API)
  3. Don’t call the the view transition if the user clicks somewhere else (—> completely avoids the freeze period)

Only then, as step 4., call startViewTransition(). In the VT callback (= during the freeze period), do the least minimal work possible to update the DOM, abort if needed.

Expressed in Pseudo Code:

async function awaitVTUpdate(abortController) {
  // 1. Run all network related code
  await getAllNetworkBoundStuff(abortController)
  // 2. Non-blocking, abortable render in background
  await renderUINonBlockingInBackground(abortController)

  // 3. if user clicks somewhere else, don't run the VT at all
  await abortController.signal.throwIfAborted()

  // 4. Start transition & update the DOM inside the freeze callback
  return startViewTransition(() => swapUIFromBackgroundToForeground(abortController))
    .updateCallbackDone // ... and return the promise
}

// example implementation:
let abortController
const myEventHandler = async () => {
  abortController?.abort()
  abortController = new AbortController()

  await awaitVTUpdate(abortController)

  // now you can set states not related to the transition
  setUIUpdateDone(...)
}

As alternative to aborting the whole transition, we can skip just the animation by using ViewTransition#skipTransition(). In that case, the VT callback will still be executed.

When using React, we’d have to make sure the view transition is started right before committing the DOM, with the DOM commit blocked until the callback of the VT has been called. One approach of doing this, would be:

To this date, I’m not aware of any React implementation that uses the FART pattern. If you do, or if you write one - please ping me. I’ll happily update this sentence!

TODO: Footnotes & Comments

Big thank you to Ivan Akulov, Noam Rosenthal and Michal Mocny for supporting the research.


📖 All posts  · ⬆️ Back to top
Next Post
Taming GTM to improve INP