yield React state updates

161 views
Skip to first unread message

Jan Nicklas

unread,
Apr 30, 2024, 5:12:40 AMApr 30
to web-vitals-feedback
We managed to pack the afterNextPaint() from Yielding to main thread and Optimize Interaction to Next Paint into a react specific hook:

Without the helper:

onClick={() => setCount(count + 1)}

With the helper:

onClick={() => startTransition(() => setCount(count + 1))}

A demo:
shot-FyoP1Hxt@2x.png
Here you can see it in action:
shot-129Um6Yu.gif

For the full code please take a look at the stackblitz demo

Do you see any drawbacks for this approach?

Barry Pollard

unread,
Apr 30, 2024, 7:20:01 AMApr 30
to Jan Nicklas, web-vitals-feedback
The two main issues I see with this pattern are:
  1. You are yielding immediately to keep INP happy without necessarily giving meaningful feedback to the user.
  2. You have not solved the main issue of the 300ms blocking task so are offloading problems for this interaction, but still have potential issues for future interactions.
For the first, it would be better to do critical updates and display just those but defer any non-critical processing. For this simple example, that would mean updating the counter, rendering that, but then doing your 300ms blocking work after that. This would be better for the user. This is more obvious if you change your blocking from 300ms to 2000ms. You can see the update happens AFTER the 2 second pause. Although INP is appeased, the user is left wondering if their click worked and may click again.

Remember that the INP metric is a proxy for user happiness. So if you please the INP metric, but don't improve the user happiness, then have you really solved the core problem of your website? Not really, and you've also made it harder to measure this user happiness now so won't know if you made it worse (e.g. if that 300ms function extended out to 400ms in a future code update).

The second issue is that ideally that 300ms blocking call should be split up into smaller tasks so it is less likely to interfere in future. To understand why this is a problem, click the useTransitionForINP button multiple times. You will see you still get a 300ms INP because the 300ms block offset from the first click, blocks the second click, so the page's INP will not improve if users are likely to have multiple interactions within a short space. Michal Mocny talked about this issue in a video for the last Google IO: https://www.youtube.com/watch?v=KZ1kxzsJZ5g&t=540s.

In fact this is one of the worries we had, and why FID only measured the input delay as can be seen from the FID docs where we stated:

Splitting that 300ms long task into 50ms or smaller task would allow the browser to interrupt at any point if a more critical bit of work comes in (e.g. the second interaction) and then come back to it later. With a single task of 300ms that is not possible.

FID only measures the "delay" in event processing. It does not measure the total event processing duration itself, nor the time it takes the browser to update the UI after running event handlers. While this time does affect the user experience, including it as part of FID would incentivize developers to respond to events asynchronously—which would improve the metric but likely make the experience worse. See why only consider the input delay below for more details.

With INP measure all interactions the risk of that going unnoticed is reduced as can be seen by clicking multiple times.

I know this is just an example, but hopefully those two things to watch out for also map to the real use case you're trying to solve for.

Thanks,
Barry

--
You received this message because you are subscribed to the Google Groups "web-vitals-feedback" group.
To unsubscribe from this group and stop receiving emails from it, send an email to web-vitals-feed...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/web-vitals-feedback/25b6ea1d-2547-4107-b17e-19b118d03b61n%40googlegroups.com.

Michal Mocny

unread,
Apr 30, 2024, 11:01:44 AMApr 30
to Barry Pollard, Jan Nicklas, web-vitals-feedback
Hi Jan,

I wanted to add some feedback specifically about the startTransition() api in React for any other folks reading this thread.
  • startTransition does not explicitly delay the start of a rendering update until after next paint, but it does do a lot of good things:
    • It signals to React that this is not an urgent update.  This changes how <Suspense> fallbacks work for updates that trigger async effects.
    • React will "fork" the state / component tree such that it can render this update with "time slicing" and "in the background", which often also means React will not start the render until after the browser schedules the next paint.
    • Any new transitions which start (i.e. new interactions that arrive) will automatically override the transition / cancel existing rendering that was still incomplete.
    • ...probably many other good things.
  • However, a few warnings:
    • In my experience I have found that it is still possible for a transition to start rendering  before next paint.  This might happen if there is a bit of idle period between the event handler ending and browser rendering work starting.  This in itself is not necessarily a problem, but...
    • Although state updates wrapped with startTransition() enable react to do time slicing (aka yield to the browser), React can only do this at the jsx / component render boundary.  If any one single component render is long running, perhaps because a specific hook calls some library code, or there is a loop doing a lot of work or something, React cannot automatically yield these things.  If you have both of these happen together: transitions start before next paint, and single component block the main thread, then you may find that startTransition is not sufficient on its own.
    • You can:
      • Fix the component and make it yield() by changing a sync rendering into an async api + use().  This can be hard.
      • Or, explicitly delay the state update until after-next-paint (still leaving it wrapped with startTransitions).  It looks like your custom hook is specifically patching the useTransition() hook to always explicitly wait for afterNextPaint()-- neat.
Also note that startTransion() has semantic differences for rendering.  It might fix INP but you are telling React "it's okay to not render this update immediately".  You should think about if that's really what the UI should be doing, and not just apply it blindly in all cases.  Barry mentioned this, but it's useful to do two things carefully after interactions:
  1. Update the UI with some simple feedback.  This could be as simple as letting buttons gets styled or textboxes render text input... but you might be able to do more, like render a skeleton DOM ui.
  2. Follow-up with any remaining non-urgent work, perhaps in an effect or in a transition.
I guess as long as you don't exclusively use this helper you created to provide all feedback to the user for all interactions, and only carefully use it for truly async effects, I think it's probably a useful tool for cases where you cannot fix component render to break apart long tasks.

Jan Nicklas

unread,
Apr 30, 2024, 11:34:11 AMApr 30
to web-vitals-feedback
Hi Barry,

Thank you so much for your detailed feedback.
I absolutely agree that simply postponing expensive operations to optimize the INP metric does not truly address the main issue.

The primary goal of deferring updates is to handle slow tasks that are beyond our control,
such as client-side route changes that result in significant DOM changes or even injecting new CSS.
As DOM updates, CSS updates and also client-side routing are often controlled by library code it can be hard to solve the problem properly.

Furthermore you're absolutely right that long tasks which slowing down the main thread for a long time without
breaks can have a negative impact on the INP of other components. Unfortunately we are facing tricky problems like lazy loading.
Let's consider a product recommender on a shop page that we choose to render later due to a slow backend or to optimize LCP.
The lazy loading can significantly slow down the browser, especially on smartphones, and negatively affect the INP value of other components.
Those cases are hard to reproduce and to track as slow INPs will not be attributed to the slow component but to the interaction target.
Do you have any tips how we could try to track this?

By default react fiber already tries to split slow updates automatically. Also the hook returns the information that it will take time to update.
Here is an example which adds visual feedback after each click: Stackblitz Demo

Also note that I split the useEffect into three side effects - that way react is able to detect the long tasks and tries to schedule them accordingly.

I don't know if you can see it in the recording but I am double clicking and although the main thread gets blocked for 1200ms in total my INP is "only" 288ms:
render-chunks.gif

@Michal thanks for your feedback!
Yes it's a little bit confusing that the custom hook uses the same api like the react transition hook.
It is also possible to make use of useTransition in the custom useTransitionForINP.ts hook but as you already mentioned it won't split a synchronous react rendering.

Best
Jan

Jan Nicklas

unread,
May 2, 2024, 8:31:11 AMMay 2
to web-vitals-feedback
Here is a real world usage example.

The click interaction triggers a tracking which is processed by a 3rd party tracking library. 
Right now the tracking slows down our INP by writing and reading cookies several times in a row.

Luckily the vendor promised to look into the issue, but until a new version is shipped there is very
little we can do to fix the root cause of the performance issue.

So we use the hook as an intermediate solution to give a visual feedback before the tracking gets
processed:
transition-hook.jpg

Barry Pollard

unread,
May 2, 2024, 8:33:03 AMMay 2
to web-vitals-feedback
But is your visual feedback also delayed with this hook? Or can you be more targeted with that and only apply to the tracking code?

Michal Mocny

unread,
May 2, 2024, 10:11:19 AMMay 2
to Barry Pollard, web-vitals-feedback
...It's hard to be sure from the screenshots, but it looks like there is still react rendering updated inside the sync click handler in the "after" case.

The hook that Jan shared gives you full control about which state updates to wrap with transitions, and to thus defer until afterNextPaint (similar to normal react transitions but with a more strict guarantee, which is especially needed for updates/effects that trigger long-tasks like above).

I guess in this case the 3p must be running as a hook or useEffect based on the state change?  Often a 3p library will directly register event listeners, and your strategy isn't helpful, but I think this technique is great for when it is!

-Michal


Jan Nicklas

unread,
May 3, 2024, 10:19:25 AMMay 3
to web-vitals-feedback
All CSS state updates would trigger immediately. So for example the removal of the :active state after a click ends.

And Michal is absolutely correct about the hook - the developer has to decide for each state wether it should happen immediately
or after the next paint.
Only state updates which are explicitly wrapped inside the startTransition will be deferred:
shot-v22AUA6A@2x.png

In addition the custom useTransitionForINP.ts hook sets an state which updates immediately.
In the following example the button disabled update would render before the next paint happens:
shot-twv00url@2x.png





Hardik Patel

unread,
May 3, 2024, 12:17:13 PMMay 3
to hardik...@dunelm.com, web-vitals-feedback
Reply all
Reply to author
Forward
0 new messages