Performance Optimization for three.js Web Animations

Paul Scanlon
Paul Scanlon
January 12th, 2022

Hello! 👋 Are you well? great! Let’s get going 🚀.

In this post I’m going to be discussing how to work with JavaScript animation libraries without negatively impacting your Gatsby site’s performance. 

If you’re keen to get cracking, below you’ll find a demo link and the src code.

Demo: https://lazyloadhero.gatsbyjs.io/

Repo: https://github.com/PaulieScanlon/lazy-load-hero

Defining Performance

Performance can mean many things, and a win in one area might sometimes mean a loss in others. 

In this post I’ll mainly be focusing on time-based metrics commonly seen in Lighthouse reports. These are as follows:

  • First Contentful Paint
  • Speed Index
  • Largest Contentful Paint
  • Time to Interactive
  • Total Blocking Time
  • Cumulative Layout Shift

In this post I’ll be paying particular attention to Time To Interactive and Total Blocking Time, and by using React.lazy and Suspense the loading of additional JavaScript can be delayed until after the initial page load.

If you’d like to learn more about Frontend performance do have a look at this webinar: Achieving Peak Frontend Performance with Gatsby.

Time-base metrics tend to be impacted by sites with large JavaScript bundles, and when using something like three.js your JavaScript bundle size will likely increase.

To optimize the overall JavaScript bundle I’ll be utilizing some methods outlined in the React docs under the Code-Splitting section. 

Here’s a little more information on Code-splitting from the React docs.

Code-splitting your app can help you “lazy-load” just the things that are currently needed by the user, which can dramatically improve the performance of your app. While you haven’t reduced the overall amount of code in your app, you’ve avoided loading code that the user may never need, and reduced the amount of code needed during the initial load.“Lazy-loading” worked great for a problem we faced recently when developing the new GatsbyConf site, however, In our scenario the animation could be considered incidental and contains no crucial site content. Your use case could very well be different.

“Lazy-loading” worked great for a problem we faced recently when developing the new GatsbyConf site, however, In our scenario the animation could be considered incidental and contains no crucial site content. Your use case could very well be different.

The Problem

Have a peek at the new GatsbyConf site, you’ll notice in the hero section at the top of the site we’ve created what I consider to be a rather nifty looking 3D star field. 

We created this using three.js / @react-three-fiber, which did unfortunately add considerable KB’s to the overall bundle size. 😬

We compared the Lighthouse scores you get out of the box when you deploy to Gatsby Cloud against the results we were seeing locally and whilst there were some differences, the overall consensus was that including these node modules had increased the Total Blocking Time and First Contentful Paint time of the site, and Lighthouse was suggesting improvements could be made.

Digging a little deeper

Using bundlephobia here’s the bundle size for both these node modules:

These numbers can be a little confusing, the Gzip size is important for when JavaScript is transferred across the network or when stored on your CDN but the larger size is what the browser will eventually compile and parse. There’s a good article on medium that explains this in more detail: JavaScript Start-up Performance 

Here’s a little more information about each of these node modules if you’re not familiar with them: 

three.js

JavaScript 3D library

The aim of the project is to create an easy to use, lightweight, cross-browser, general purpose 3D library. The current builds only include a WebGL renderer but WebGPU (experimental), SVG and CSS3D renderers are also available in the examples.

react-three-fiber

react-three-fiber is a React renderer for threejs.

Build your scene declaratively with re-usable, self-contained components that react to state, are readily interactive and can participate in React’s ecosystem.

The Solution

The solution in our case was to “lazy-load” the React component (<ThreeCanvas />) that contained the import to @react-three-fiber, and here’s a snippet of how it works.

I’ll start at the top and work down to explain what’s going on.

  • Lazy Import
    • This is how to use React.lazy to delay the importing of a component until after the page has fully loaded. In the demo project <ThreeCanvas /> contains the import for @react-three-fiber.
  • State
    • I’ve defined a state value called isMounted and set it’s initial value to false
  • Lifecycle
    • As you may already know, React’s useEffect lifecycle method only runs in the browser, this is helpful because Gatsby’s build process happens on the server, and Suspense relies on the window object which won’t be available during Gatsby’s build step.
  • Lazy Load
    • Here’s where the interesting stuff happens! Suspense accepts a React component or null on the fallback prop, and renders its children when the initial page load is complete. 

A note on the null ☝️. I mentioned this earlier, but in GatsbyConf the animation is incidental, and really, it doesn’t matter if it’s there or not, it also doesn’t matter if there’s nothing displayed in its place while the “lazy-loading” is happening. If you’d like to display a fallback component or a loading spinner perhaps, you could use the fallback prop to render any kind of component you like. Suspense will remove the fallback and show the children when the initial page load is complete.

But wait, there’s more!

Lazy loading is great but eventually, and after the initial page load, the remaining JavaScript will still be downloaded.

When we were optimizing GatsbyConf we discussed if it was necessary to load the animation for users on smaller screens / cellular devices – we decided it wasn’t.

After all, these screens are quite small and users may not really see the animation. Furthermore if a user has saveData enabled we can tap into that and respect their preferences. 

The saveData attribute, when getting, returns true if the user has requested a reduced data usage mode from the user agent, and false otherwise.

In the diff below we’ve added two additional conditions to the ternary, these are there to prevent <ThreeCanvas /> from being loaded if:

  1. The browser’s min-width is less than 768px.
  2. navigator.connection.saveData is true.

 

Regarding point 1

It’s worth noting that desktop browsers could quite easily be less than 768px. It’s more than possible someone visiting the site might have resized their desktop browser, in which case, the animation wouldn’t be loaded.

Regarding point 2

saveData is an experimental feature, you’ll see in the snippet above we’ve used optional chaining to determine if this feature is available in the user’s browser (optional changing is quite well supported but do check your browser compatibility and Node version if you run into problems). saveData will return true or false depending on a user’s settings. You can use this in your condition to help determine when to “lazy-load”.

Why the additional conditions?

It’s important to note that these additional conditions aren’t there to “hide” the animation, they’re there so the JavaScript isn’t loaded at all. There’s a big difference! 

Using CSS to hide an element on “mobile” only means it’s not visible, but the HTML elements and / or any JavaScript used are still present in the page. — I suppose it’s a bit like wifi, the wind, or my Gran’s secret Whisky stash. You can’t see it, but it is there.

Considerations 

… and it’s here where you really need to think about the pros and cons to “lazy-loading”. 

Important content shouldn’t really be “lazy-loaded”, you’d want this content visible (and present in the page) as soon as possible, and not just because of SEO and Lighthouse scores, but for your users. 

If this content is wrapped up in “lazy-loaded” fancy animations you might, in the long run, be delivering a poorer user experience even though the eventual result is delightful. 

In our case on GatsbyConf our important content is separated from the fancy animations, and yes, whilst there is a delay before the stars come to life, we feel for GatsbyConf a performant page load is more important than a “nice to have animation”.

Closing thoughts

I believe in a middle-ground. For those who don’t know me, I started life as a Flash / ActionScript developer many years ago and back then (for better or worse) all we cared about was how fancy websites could be, but when Flash died (circa 2012) I feel like we took a bit of a step backwards with web creativity. 

Today however, there are options, there are many more considerations of course but I think it’s possible to strike the right balance between ultimate performance and accessibility whilst still exercising creativity.

I’d love to see what you folks are doing in this space and if you’re working on something cool or just want to chat, feel free to come find me on Twitter: @PaulieScanlon.

Ttfn

Paul 🕺

Paul Scanlon
Written by
Paul Scanlon

After all is said and done, structure + order = fun! Senior Software Engineer (Developer Relations) for Gatsby

Follow Paul Scanlon on Twitter

Achieving Peak Frontend Performance with Gatsby