Spinners versus skeletons in the battle of hasting

Martin Nuc

Martin Nuc

Software engineer at Productboard

Agenda

  1. Performance issues in PB
  2. Theory of loading states
  3. Two issues we had
  4. How Design system helped

"Productboard is slow to load"

Huge JS bundle

10.8 MB

Slice up the Productboard

โœ‚๏ธ

split app into chunks by routes

const loadMyComponent = () =>
  import(
    /* webpackChunkName: "awesome-chunk" */
    'src/js/MyComponent'
  );

const LazyMyComponent = React.lazy(loadMyComponent);

<Suspense fallback={<LoadingSpinner />}>
  <LazyMyComponent />
</Suspense>

Chunks (~60)

10.8 MB down to 3.8 MB

Delay when navigating

๐Ÿ˜ 

How to identify loading state in the app?

UX Theory

๐ŸŒ€ Loading spinner

๐Ÿฅ Progress bar

โ˜ ๏ธ Loading skeleton

Don't do's

โŒ Multiple spinners

โŒ No response on click

โŒ Short spinner

โŒ Long spinner

Chunks (~60)

Three cases

๐ŸŽ really fast parts

๐Ÿš™ reasonably fast

๐Ÿข slow large pages

300ms

1500ms

Spinner

Skeleton

No indicator

Two issues

  1. the fallback is always rendered
  2. identify slow pages

1st issue

the fallback is always rendered

๐ŸŽจ

=

spinner shows even when not necessary

๐Ÿคฌ Suspense fallback is always rendered

Suspense

Suspense

  • prevents rendering it's children component until its data are ready
  • experimental
  • API for React.lazy + Suspense won't change
const Suspense = ({children, fallback}) => {
  try {
    return children;
  } catch {
    return fallback;
  }
}

๐Ÿค” When is children ready?

const Suspense = ({children, fallback}) => {
  try {
    return children;
  } catch(thrownPromise) {
    thrownPromise.then(() => rerender());
    return fallback;
  }
}

Fallback always rendered

const Suspense = ({children, fallback}) => {
  try {
    return children;
  } catch(thrownPromise) {
    thrownPromise.then(() => rerender());
    return fallback;
  }
}

First render always throws

๐Ÿ’ก postpone rendering of fallback

"DelayedComponent"

Usage

<Suspense fallback={
  <DelayedComponent delay={300}>
    <LoadingSpinner />
  </DelayedComponent>
}>
  <ComponentFetchingData />
</Suspense>
export const DelayedComponent = ({children, delay}) => {
  const [shouldDisplay, setShouldDisplay] = useState(false);
    
  useEffect(() => {
    const timeoutReference = setTimeout(() => {
      setShouldDisplay(true);
    }, delay);

    return () => {
      clearTimeout(timeoutReference);
    };
  }, [delay]);
  
  if (shouldDisplay) {
    return children;
  } else {
    return null;
  }
}

โœ… fallback renders only when needed

2nd issue

Identify slow pages

โ˜ ๏ธ

๐Ÿ™ we had no visibility into components rendering

Identify slow pages

๐Ÿ’ก track DelayedComponent lifecycle

DelayedComponent

nothing

Spinner

Suspended content

loading done

300ms

โฑ rendered

โฑ fallback displayed

โฑ component unmounted

โœ… identified slow pages (>1500ms)

๐Ÿ”ง crafted skeletons for them

๐Ÿงฉ Design system

  • helped tremendously!
  • speedup crafting skeletons

๐Ÿ‘

๐Ÿฅก Take aways

  • measure your app performance
  • pick the best solution based on โคด๏ธ
  • deliver the best UX to the user

Q&A

Is it perfect?

Spinner

300ms

350ms

โŒ Fast spinner here!

Finished loading

It's a trade of

Spinner

300ms

350ms

Finished loading

600ms

250ms

no fast spinner or faster render?

Made with Slides.com