Video Summary and Transcription
The Talk discusses the InteractionToNextPaint metric, which measures the speed of clicks or keyboard inputs on a page. It explores the impact of slow interactions and slow React renders on user experience. The Talk also covers optimization techniques for React rendering, including the use of concurrent features and the StartTransition function. React 18 introduces changes to the rendering process that improve interaction speed. Concurrent rendering and suspense boundaries are highlighted as features that can enhance the performance of React apps.
1. Introduction to InteractionToNextPaint metric
The PageSpeed Insights report for newyorktimes.com and target.com, both written in React, have all their speed metrics green, except for InteractionToNextPaint. This metric, introduced by Google, measures how fast clicks or keyboard inputs are on the page. In almost every React app, this metric is yellow or red, indicating a bad user experience. As this metric becomes a core vital in March 2024, your boss and marketing team will also become aware of its impact.
I start this talk with bad news. This is the PageSpeed Insights report for newyorktimes.com, written in React. It has all its speed metrics green, except for InteractionToNextPaint.
This is the PageSpeed Insights report for target.com, also written in React. It also has its speed metrics green, except for InteractionToNextPaint.
This is the Notion website. It also has all its speed metrics green, except for InteractionToNextPaint. This metric, InteractionToNextPaint, is a new performance metric introduced by Google. It measures how fast clicks or keyboard inputs are on the page. In almost every React app I've seen so far, this metric is yellow or red. Which means bad user experience, and which also means, as this metric becomes a core vital in March 2024, that also means that just in a few months your boss and your marketing team will also become suddenly aware that this metric is yellow or red.
2. Understanding the Impact of Slow Interactions
The core idea behind this new metric is that it tells you how slow your app is. It calculates the slowest click or keypress on the page and sends it to Google servers. The INP value is computed by Google based on the slowest interactions from multiple users. In practice, a slow filter action in a note-taking app can make the app unresponsive. The interaction to an expanded metric is affected by slow interactions. By using the web vitals extension and checking the console, you can see the impact of slow interactions on the INP value.
Now, who here has worked with this metric before? Has anyone tried debugging or optimizing it? Actually, I can't see anything from here because of the lights. Well, all right, let's take a really quick dive into a theory.
So, the core idea behind this new metric is that it tells you how slow your app is. And when you see an interaction to next point of, for example, 393 milliseconds, here's how it's calculated. First, when you open any app or website on your Chrome, Chrome measures every click or keypress that you do on the page and finds the slowest one of them. Then, Chrome takes that slowest value and sends it to Google servers saying, hey, for this session the INP value was, for example, 500 milliseconds, which means the slowest click or keypress on the page was 500 milliseconds. And then, as more users visit the page and do the clicks and send their INP values, Google does the statistics magic and computes the overall INP for all the visits. So, that's what INP is. End of theory.
Let's see how it looks in practice. So, here is a very basic note-taking app. You could open a note, you could type into a note, it supports markdown, you could create a new note and you could also filter notes. And the filter not action, it's pretty slow. So, here in this corner, you could see this spinning bar, which is a spinner, which shows when the app is responsive. So, when the spinner spins, that means the app is responsive. When the spinner freezes, that means the app is also frozen. And if I try to type into this filter input, you could see that the moment I type, the spinner freezes for half a second or a second. This means typing into the filter is very unresponsive, I can feel that, and the page holds freezes for that period of time. So, that is about user experience. And this also worsens the interaction to an expanded metric.
Now, there's a very easy way, there's a very simple way to see how exactly the interaction to an expanded metric is affected by this. To see this, I'm going to go to the Chrome web store, I'm going to find the web vitals extension, I'm going to install it into Chrome. I'm going to open its options, enable console login, and then open the console on my page. And now, if I reload the page, and if I look into the console, I would see every core vital, every performance metric of the page of the app logged into the console. And if I try to interact with the app, like, for example, selecting some text, right, or just clicking some random places on the page or opening notes, I would see how long any interaction on the page I took. I mean, took. And if I try typing into the filter input, I would see that typing into the filter input is very, very slow. The interaction takes 500, 600 milliseconds and that results in a red INP. And you can also see how the more I interact with the page and the worse the interaction time gets, the higher the INP value also gets. Like INP is just the slowest interaction that's happened on the page, right? So you could see that every time I make an interaction that gets even slower than it was before, the NP value also goes up.
3. Understanding the Impact of Slow React Render
My app is slow due to an expensive React render. This re-rendering process causes the input to be slow and leads to a poor user experience. To address this, React 18 introduces concurrent features, making updates non-urgent and allowing for prioritization of updates.
So, this is how INP is calculated. All right. Now, my app is slow, and INP is clearly showing that, right? And I could feel it, of course, on my own. Now, like IRB and like Target websites earlier, my app is also built with React. And if I were to debug this performance issue, I would see that this issue is caused by an expensive React render.
What's happening in the app basically looks as follows. So I type in the filter input. That filter input calls the setfilter function. That, in turn, changes the state in a bunch of components, and that causes React to render all these components one by one until React is done. And this is what makes my input slow. This re-rendering is a stop the world operation. Nothing could happen until the render is done. This is just how React works, right? When I type it on the keyboard, the page won't update until React has finished processing all the components. And if it takes two seconds to process all the components, then the page won't update until two seconds later. This is the classic slow React render, and this is what makes interaction to an explained bit.
Now, we have a performance problem, right? I type in the filter, and the app freezes. And that's a terrible user experience, and that also makes interaction to an explained bit. Now, normally if I were a developer on this app, I might try multiple things to fix this. I might try writing stuff with React Memo, optimizing the components, maybe virtualizing the list of nodes. Ken is actually going to talk about this later. I could try debouncing or throttling some stuff. And these are all great solutions, and you can even combine them. But here's the cool bit. What React 18 does is it introduces another solution to the mix, which is officially called concurrent features, and which I call making updates non-argent. What does it mean? So with React 17 and below, every update that happens in the app is considered argent. If you click a button, React has to handle the update immediately. If you type into the filter input, React has to render the list of nodes that we have in the app immediately. With React 18, however, your updates can now have a priority. Every update you make in the app is still by default argent. But what React now also supports is non-argent updates.
4. Optimizing React Rendering
And non-argent updates, almost magically, don't block the page, no matter how long they take. React-TT allows splitting a single piece of state into two bits and connecting them to different components. By rendering non-urgent updates, React can prioritize the filtering rendering that users care about, while the other parts can wait.
And non-argent updates, almost magically, don't block the page, no matter how long they take. Let's see how this works.
So, oops, it went in the wrong direction. Here is the code for my app. This is the node list component, which is responsible for this sidebar that I have on the left. And it has two big things in it. It has the filter input component, which is this component. It has the list of nodes, which is this list of nodes, this huge list of all 1,500 nodes that I have in the app. And it has the filter state, which controls both the filter inputs and the list of nodes.
And so now, in this app, every time I type into the filter input, the filter state updates, and that state update is considered urgent. So React has to take that filter update and it has to render all the components in one go. I type, and it's slow. Right? So now, with React-TT, with React concurrency, what I can do is I could take this state update and I could take React that, hey, some parts of this state update are non-urgent. And here's how I'm going to do this.
So the first thing that I'm going to do is I'm going to take this single piece of state and split it into two bits. So I'm going to call the first filter input. And the second one filter value. Second, I'm going to take these two separate bits of state and I'm going to make them control different parts of the UI. So the filter input is going to control my filter input, obviously. And the filter value is going to control my list of nodes. And now I also need to update both of these states when I type into the filter input and that changes. So I'm just going to do this. I'm going to receive the value and I'm going to call set filter input, set filter value. Thanks, Copilot.
All right, so I took one bit of state and I split it into two bits of states and I connected these two bits of state to two separate components. So far this does not do anything, it just makes the app worse. Instead of one piece of state I have two pieces of state but the app is still slow, you can see I'm typing, it's terrible and it basically doesn't do anything else. But this is where the third, the key, bit of the puzzle comes.
Now that I have two bits of state that updates independently I could take the second bit of state that's responsible for the whole Nots list and I could tell React, hey React, when you make this state update, when you make a render that's triggered because the state changed, could you render it non-urgently? Because I as a user I don't really care if the Nots render right away, right? I care that the filtering that I'm typing into rerenders right away, I want to type and I want to see the letter that I typed in the right way. But the Nots, they could wait, they could wait half a second, they could wait a second, that would be just right.
5. Optimizing React Rendering with StartTransition
To make the SetFilterValue state update non-urgent, import UseTransition from React, call the UseTransition hook with IsTransitioning and StartTransition values, and wrap the SetFilterValue call with StartTransition. This change improves user experience by preventing lag and freezing during interactions. It gives control back to the browser, allowing React to render the app and return control after every frame. React's queue of updates prioritizes non-urgent components.
That would work just fine. So I'm going to tell React that my SetFilterValue state update is non-urgent. And to do this, here's what I'm going to do. So first I'm going to import UseTransition from React. Second, I'm going to call the UseTransition hook, receiving two values. IsTransitioning and StartTransition. And third, I'm going to take my SetFilterValue call and I'm going to wrap it with StartTransition. That's it.
And so now, look at this spinner. I've done just a single change. I took my SetFilterValue and I've wrapped it with StartTransition. And before, without StartTransition, if you look at the spinner, you could see I'm typing and it lags, right? I'm typing and it's slow, it's annoying, it's bad user experience. It also makes the interactions really, really bad.
Now, if I wrap my SetFilterValue with StartTransition, what's going to happen is I'm going to type it in my filter input and nothing is going to freeze. So you could see in the right pane all the interactions are getting locked. They are green. And as I'm typing into my filter, the spinner just keeps spinning. Nothing is freezing. The app is completely responsive. I could type as much as I want and it's absolutely fast. The button is literally gone. This is almost magic. All right.
Now, could anyone guess how this works? This is a question to the room. Magic was the right answer, yes. So, no, the way this actually works is giving back control to the browser. React starts rendering the app and then gives the control back to the browser after every frame. Here's how this works from the React perspective. So, React has a queue of updates. In our case, this queue of updates has components that don't need to be updated urgently.
6. React 18 Rendering Process
React 18 introduced two critical changes. First, it added a check called should yield to hosts, which determines when to give control back to the browser. Second, it schedules the next chunk of JavaScript activity using set-timeout zero. With React 18, state updates are categorized as argent or non-argent. Argent updates are rendered in a blocking manner, while non-argent updates are rendered in a non-blocking way, allowing control to be returned to the browser every five milliseconds.
It's the nodes list and a bunch of node buttons. So, React has a queue of updates. And React also has a function called perform work until deadline. This function basically takes the queue of updates and renders them one by one. Now, in React 17, this was pretty much it. You would start processing the queue, and you'd keep processing the queue until you're done. All this time, the page would be blocked. That was the Argent render.
Now, React 18 added two critical changes. First, in the while loop, it added a check called should yield to hosts, which tells React whether it should give the control back to the browser. And second, after the loop, React now checks whether there are still any pending unprocessed updates and schedules another perform work until deadline function to be executed in the next frame. So, take a queue, process it, return the control back to the browser if should yield to hosts tells you that you should return the control back to the browser, and then schedule more work if you return the control back to the browser early.
Now, should yield to hosts, the function that decides when React should give the control back to the browser is basically one-liner. It simply returns true if the current render has been taken more than 5 milliseconds. Now, schedule perform work until deadline function, the function that schedules the next chunk of JavaScript activity, the next chunk of processing the update queue, it's also pretty simple. It just calls the set-timeout zero. Who said that? Yay. It just calls the set-timeout zero to schedule the next perform work until deadline call or set immediate or message channel if there are supported React users damaged. And that's basically how it is implemented.
If you remember the slide from the past, this is how React 17 behaved when you tried the filtering input. You typed in the field. It changed the state in all of the components and then React had to process all of the components one by one. Freezing the page the whole time. Now, with React 18, this changes. When I tried typing into the filter field, React calls set-filter input and then calls set-filter value. This also causes the state to update in a bunch of components. But now, some of these state updates are argent, whereas other ones are marked as non-argent. And so, what React now does is it renders the argent updates in the old blocking manner, keeping the page frozen. But then it starts rendering non-argent updates. The updates wrapped with start transition in a non-blocking way, giving the control back to the browser every five milliseconds.
7. Improved Interaction with Concurrent Rendering
And because the browser gets the control back, it can repaint the page much sooner. The first interaction is going to be faster, because we aren't blocked by all the non-argent components. And subsequent interactions are going to be faster. In Rec 17, the button would not react until the render is complete, but with concurrent rendering, the browser gets the control back every five milliseconds. So, it will dispatch the on-click handler right away, and then repaint the screen also right away. This would make the interaction process much faster and improve the interaction to Next Paint.
And because the browser gets the control back, it can repaint the page much sooner. It could handle all the interactions much sooner. The first interaction is going to be faster, because we aren't blocked by all the non-argent components. And subsequent interactions are going to be faster. Like, let's say the user tries to click something on the page, while the previous render, the non-argent render, is still ongoing. So, there is something to the filter input, and right away click something on the page, right? Within the next ten milliseconds. In Rec 17, the button would not react until the render is complete, until the previous render is complete. Which can take half a second, a second, two seconds, but with concurrent rendering, the browser gets the control back every five milliseconds. So, it will dispatch the on-click handler right away, and then repaint the screen also right away. And this would make the interaction process much faster, and this would also make your interaction to Nxt Paint much better. This is Rec 17's concurrent rendering, and this is how to browse interaction to Nxt Paint.
Concurrent React Features for Hydration
React18 allows concurrent hydration by using suspense. Splitting the page into sections and wrapping each section with suspense enables non-blocking hydration. Wrapping the entire page with suspense can cause forced argent hydration, delaying the next paint. By wrapping separate page sections with suspense, only the clicked section hydrates argent, resulting in a shorter interaction to expand. These concurrent React features help improve INP.
Now, what we talked about so far was just one concurrent feature, useTransition. There's also another concurrent feature, which is less known, and it is suspense during rect hydration. So, if you're not familiar with rect hydration, the process of rect hydration is basically a process when you server render a site, then on the client hydrate that site, rendering every rect component again, attaching event listeners to the already existing DOM, and then you get the live set.
Now, hydration in my experience is typically the most expensive JavaScript operation that rect app could have, simply because rect has to render every single component that exists on the page, like, for example, here's delivery spending 1.55 seconds hydrating the site with 4x CPU throttling, or here's Notion spending 1.8 seconds, or here's Walmart spending 1.1 seconds, and I don't mean to dunk on this website or these teams. This is a standard situation. Every React site has this. This is just how rect hydration works. And what this means for interaction to NextPaint is, if you click the page somewhere during the hydration, then the browser won't process that click and won't repaint that screen until hydration is done because the browser is just blocked running the hydration, and that could happen. The hydration could end half a second or second later. So that results in a terrible time of interaction to NextPaint, but what React18 now allows you to take your site and hydrate it concurrently. In the same way that use transition works for interactions, suspense works for hydration. To do this, you need to take your page, like this notion landing page, for example, split that page into sections, like a header, a hero block, more sections, some further down, and wrap each of the sections with suspense, which would basically make the cut like this. And so what would happen now would be basically the following. You would call hydrate root. Rect will start rendering components one by one until it at some point stumbles upon the suspense boundary, which marks the non-blocking hydration, and when rect stumbles upon this boundary, it wouldn't proceed past that boundary just yet. It would keep rendering the remaining argent components until it's done, and then finally will render the non-argent ones given the control back to the browser every five milliseconds. So, if you wrap every page section with start-transition, what would happen is every page section would be hydrated non-concurrently given the control, sorry, concurrently given the control back to the browser every five milliseconds. Just like we use transition. Now you might ask, okay, if this is so good, if this makes the page hydrate non-blockingly, why do I need to bother with sections? Why don't I just wrap the whole page with suspense? And the answer is it's actually, it would actually flop your INP. Why? This has to do with another suspense behavior from React18. It doesn't really have a name, but I call it forced argent hydration. The challenge behind it is the moment you click some suspense button, React switches it back to argent hydration, so if you wrap the whole page with suspense, it will hydrate like this, 5 milliseconds at a time, but if you click it right when it's hydrating, React will switch to the argent hydration for the full page, which means the next paint won't happen until the whole page is hydrated, whereas if you wrap only separate page sections with suspense, which will give you a rendering that looks like this, and then click some suspense boundary, then only that suspense boundary will hydrate argent, which will take much less time and will make the first render after the click happen much earlier and will make your interaction to expand much shorter. So this is why you don't want to wrap the whole page with suspense. All right, so this is the two big concurrent React features. This is how they work and this is how they help with INP. Take them, take your INP and go make your INP green. Thank you.
Thank you for the questions. The first one says, is there any reason not to wrap most state updates in start transition? That's actually a great question. Hold on, let me think.
React State Updates and Suspense Boundaries
I don't know. So, I think there are, I mean, there are some, there's definitely an answer to this. Sorry, I can't think of it. Well, I'm going to give Ivan homework. You're going to send us a Tweet about it later on so that they can find the answer for you. The scenario where used transition or used deferred value is helpful is when you're wrapping the state or reducer updates with them. You can also wrap the initial hydration or render with start transition. The cool thing about start transition is that it can wrap updates at any level of nested functions, even if it's a redux state update or another function that updates the state. The question of how granular suspense boundaries should be is important, as you may not want to wrap the entire page with suspense.
I don't know. So, I think there are, I mean, there are some, there's definitely an answer to this. Sorry, I can't think of it. Well, I'm going to give Ivan homework. You're going to send us a Tweet about it later on so that they can find the answer for you. Perfect. Find Ivan on Twitter slash X for response to that.
The next one we have is, what scenario than set state is used transition or used deferred value helpful? Could you say that again? Sorry. Yes. So, what scenario either than set state is used transition or used deferred value helpful? Oh yeah. I see. So, I mean, there are two ways, there are only two ways to update the state in your React app. It's used state or used reducer, right? So, at the basic level it's useful when you're wrapping either of these with used transition or used deferred value. You could sometimes also wrap the initial hydration or the initial render with start transition which you would in that case import straight from React and that would make the initial render or the initial hydration also concurrent. But in general, so in my example, I took used transition and I wrapped directly the set state call with the used transition, right? But that doesn't have to be the case. Sorry, I'm... No, you're okay. Yeah, I still haven't had my lunch. Oh, hungry! Well, let's give him a round of applause for doing this hungry. I would not be able to do that. But I want to finish answering. Okay, let's go ahead and finish. Thank you, that was helpful. Anyway, so the cool thing behind start transition is that it doesn't have to wrap the used state or set state at the basic level. Like, it could be it could be behind 10 levels of nested functions, right? If it's some redux state update or some other function that updates your state by proxy, you could still wrap it with start transition. It would still do the magic. Amazing, thank you. Let's see if we can take one short one. Okay, how granular should suspense boundaries be? Yeah, oh, this is a great question. So if you watch this presentation, you might wonder, okay, so I don't want to wrap the whole page with suspense.
Optimizing with Suspense
Why not wrap every component with suspense? The React team says you can, but be cautious. Suspense has additional functions besides improving hydration, such as suspending data loading or lazy components. Excessive use of suspense can accidentally make unintended parts of the page suspenseful. The recommended approach is to wrap every page section for optimal efficiency.
And I explained why, right. But why don't we go in the opposite direction? Why don't we wrap every component with suspense? And I actually asked the React team this question and they were like, yeah, you could do that. The only reason you might not want to do that, that suspense actually has more functions. So apart from making the hydration better, it also suspends if you have some data loading or lazy components. And if you put too many suspenses around, you could accidentally make some parts of the page suspense when you don't want them. So the heuristic that I like is wrap every page section and that works well.
Optimize and be efficient.
Yes. Thank you so much, Ivan. Thank you.
Comments