10000 [React 19] Suspense throttling behavior (`FALLBACK_THROTTLE_MS`) kicks in too often · Issue #31819 · facebook/react · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

[React 19] Suspense throttling behavior (FALLBACK_THROTTLE_MS) kicks in too often #31819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
uhyo opened this issue Dec 17, 2024 · 17 comments
Open

Comments

@uhyo
Copy link
uhyo commented Dec 17, 2024

React version: 19.0.0

Related Issues: #30408, #31697

This may be the intended behavior, but I'm opening this issue anyway because enough discussion hasn't been done in the related issues IMO and the current behavior still feels poor to me in that it makes it too easy to slow down actual user experience.

Steps To Reproduce

Code
const zero = Promise.resolve(0);

function getNumber(num) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(num), 100);
  });
}

function App() {
  const [count, setCount] = useState(zero);

  const countValue = use(count);

  useEffect(() => {
    console.log("countValue =", countValue, Date.now());
  }, [countValue]);

  return (
    <button
      onClick={() => {
        console.log("click", Date.now());
        setCount(getNumber(countValue + 1));
      }}
    >
      count is {countValue}
    </button>
  );
}

In short, when a rerendering suspends, you always have to wait for 300ms even if underlying data fetching has finished sooner.

In the attached example, when user pushes the button, a new Promise is passed to use(), which triggers Suspense. Even though that Promise resolves exactly after 100ms, the UI is updated only after 300ms.

I experienced this issue when using Jotai, a Suspense-based state management library.

Given that the throttling behavior kicks in even in this simplest situation, it seems impossible to implement a user experience that involves Suspension and is quicker than 300ms regardless of, say, user's network speed.

Link to code example:

https://codesandbox.io/p/sandbox/4f4r94

The current behavior

Almost always need to wait for 300ms.

The expected behavior

Maybe some better heuristic for enabling the throttling behavior? It would also be very nice to make this configurable.

@uhyo uhyo added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Dec 17, 2024
@eps1lon
Copy link
Collaborator
eps1lon commented Dec 18, 2024

The alternative of quickly flashing the Suspense boundary fallback isn't better from our experience. In real apps, that would usually mean unmounting a large chunk of the screen for a very short period which doesn't make for a pleasant UX.

There isn't a correct number here since this is just a heuristic. 300ms felt like a good middleground between avoiding jank and feeling too sluggish.

A real-world example would help illustrate the issue.

Keep in mind, that you can always wrap the update in startTransition and display smaller fallback while isPending from useTransition is true.

@eps1lon eps1lon added React 19 and removed Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug labels Dec 18, 2024
@uhyo
Copy link
Author
uhyo commented Dec 18, 2024

@eps1lon Thank you for the response. I have two questions now:

First, I understand that flashing UI isn't good user experience, but I still don't see any reason to make user wait for extra hundreds milliseconds, especially when the situation is this simple where there is only one ongoing suspension.

300ms at maximum isn't always a reasonable cost for making the UI look a bit less janky IMO.

Secondly, I see that useTransition could work. However if I understand correctly transitions are for non-blocking updates; in other words, state updates are marked as non-urgent if performed within a transition.

Using transitions for letting user see new data more quickly is quite counterintuitive to me. Am I getting anything wrong?

I don't have a truly real-world example but I think I can prepare something that looks more real-worldy if wanted.

@Tasin5541
Copy link

There are use cases where the suspense bound components are very small and can take less than 100ms to load (depends on the server as well). We used to not show anything as a fallback to avoid the jankiness. This is intentional, so that initial bundle size is low but end user also doesn't have to see these fallbacks for every little lazy loaded components. Now with this 300ms hold up, there's no other way than showing a fallback, which in turn feels like a worse UX. Instead of making this behavior the default, it should be opt in based.

@domarmstrong
Copy link

It appears that any time a lazy component is initialised it also kicks in the 300ms suspense, even if there is nothing that needs to load. We have our bundle split so we may have 10 or so lazy wrapped component (views) in one chunk, so the first time you hit one of the views the chunk is loaded, then switching between the views is instant. Now it seems that you have to wait 300ms and show a spinner even though nothing is being loaded after the initial chunk load, which is a notable degradation from an instant page change. After the lazy components initialise the changes are instant though on subsequent changes. But the artificial pause really isn't great.

@rossipedia
Copy link

Instead of making this behavior the default, it should be opt in based.

I'm fine with defaults, as long as there are escape hatches. This should 100% be configurable, with the option to disable it entirely. It's not really React's responsibility to make this kind of choice for every app.

@Janpot
Copy link
Janpot commented Jan 8, 2025

It appears that any time a lazy component is initialised it also kicks in the 300ms suspense, even if there is nothing that needs to load. We have our bundle split so we may have 10 or so lazy wrapped component (views) in one chunk, so the first time you hit one of the views the chunk is loaded, then switching between the views is instant. Now it seems that you have to wait 300ms and show a spinner even though nothing is being loaded after the initial chunk load, which is a notable degradation from an instant page change. After the lazy components initialise the changes are instant though on subsequent changes. But the artificial pause really isn't great.

Seeing similar behavior, but only under webpack. Reproductions in mui/material-ui#44920 (comment).

@domarmstrong
Copy link

So I can remove the 'always 300ms' fallback by using useDeferredValue in tandem with a use of useSyncExternalStore as noted here (which honestly makes no sense as a dev/lib user, sync updates cause an async fallback, but making it async avoids the async fallback to show, wat?) . Similarly you could wrap any state calls that cause lazy components to render in startTransition for a similar solution.

However, mixing the use of deferred values/transitions with any non deferred/transition code can easily cause bugs. It's also sadly very easy to do. You need to be aware of what the internals of some hook or util may be doing.

So the current situation seems to be:

  • use deferred/transitions - lots of foot guns and potentially easy to have bugs. Also the whole team needs to suddenly be thinking about another concept that previously didn't exist and wasn't a problem (this really puts me off, we didn't have a problem before that this is solving).
  • live with 300ms delays when there is no need for them

I'll take a bit of flicker sometimes to avoid either of these. So configurability would be greatly appreciated.

@oliviertassinari
Copy link
Contributor

A Reddit discussion about this https://www.reddit.com/r/reactjs/comments/1hjoplz/react_19_scheduler_does_something_silly/

@wjessup
Copy link
wjessup commented Feb 18, 2025

please add config option here. 300ms sucks.

@eg-bernardo
Copy link

I'm also encountering this issue when using lazy routes on tanstack router and it's not clear to me how I could use startTransition there.

This behaviour should really be opt-in not opt-out.

@olekolek1000
Copy link

300ms is still a significant amount of time. Many people have tried to pinpoint this exact problem, as their web applications significantly slowed down in terms of UX after migrating from React 18 to 19.

@benkingcode

This comment has been minimized.

@itsdouges
Copy link

Wow this is horrible. My UI which was previously instant (because a suspense React Query would resolve instantly when cached) is now always waiting 300ms.

This has to be reverted.

It happens when triggering suspense boundaries. Was yours doing that but just resolving super fast so no one noticed?

The canonical solution for now is to put the state change in a transition and it'll appear as before. But watch out if you're using useSyncExternalStore as the transitions don't work with it... which is kind of exacerbated by this change tbh 😅.

@Mikephii
Copy link
Mikephii commented May 4, 2025

This is really really really frustrating, my "loaders" are skeleton components that perfectly match the dimensions of my content, eg squares that should have photos. "flashing" the skeleton for 1ms and then fading into the loaded photo is totally fine and not at all jank.

Now with react 19, even if all of my content is immediately available in a local cache, i still need to load all my content for 300ms?!

why?!

@jvandenaardweg
Copy link

The alternative of quickly flashing the Suspense boundary fallback isn't better from our experience.

Then make it configurable. This isn't a decision the React gods should have over every React app.

@bogdanailincaipnt
Copy link
bogdanailincaipnt commented May 7, 2025

I put together a hackish React.lazy alternative that solves the situation where the Suspense fallback is shown even when the component had already been imported before it was rendered.

Basically, I am storing the resolved import in the SWR cache, with the stringified importer function (with toString) serving as the cache key. This can also be adapted for usage with other caching solutions than swr.

I noticed that even if the import path used by preloadComponent is relative and the import path used by lazyWithCache is absolute, or viceversa, it still works. The stringified function always includes the absolute path.

Does anyone see any issues with it? It seems to work fine. I tested in dev/prod build, different browsers, local/deployed. I only tested Vite as a bundler.

Maybe we could have a preloadComponent function offered directly by react which would be automatically tied to React.lazy() usages behind the scenes.

import useSWRImmutable from 'swr/immutable';
import { mutate } from 'swr';
import { useErrorBoundary } from 'react-error-boundary';

type ImportComponent<TProps extends object> = () => Promise<{
  default: React.ComponentType<TProps>;
}>;

const getSWRKey = <TProps extends object>(
  importComponent: ImportComponent<TProps>,
) => importComponent.toString();

/**
 * Paired with `lazyWithCache`, this solves the issue where React briefly shows a Suspense fallback for a component loaded with React.lazy() even when the component had already been imported before it was rendered.
 *
 * Usage:
 *
 * `preloadComponent(() => import('path/to/component'));`
 */
export const preloadComponent = <TProps extends object>(
  importComponent: ImportComponent<TProps>,
) => {
  void mutate(getSWRKey(importComponent), importComponent);
};

/**
 * Usage is similar to `React.lazy`:
 *
 * `const LazyLoadedComponent = lazyWithCache(() => import('./path/to/LazyLoadedComponent'));`
 */
export const lazyWithCache = <TProps extends object>(
  importComponent: ImportComponent<TProps>,
) =>
  function Lazy(props: TProps) {
    const { showBoundary } = useErrorBoundary();

    const {
      data: { default: Component },
    } = useSWRImmutable(getSWRKey(importComponent), importComponent, {
      suspense: true,
      onError: showBoundary,
    });

    return <Component {...props} />;
  };

@domarmstrong
Copy link

I also ended up reimplementing lazy without suspense and removing any other suspense boundaries. It was fairly simple and resolved the problems with this unfortunate decision. I just hope I can continue to avoid having to use these 'async' features.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

0