loading...
Cover image for Better Performance using Dynamic Code Splitting in Gatsby with loadable-components

Better Performance using Dynamic Code Splitting in Gatsby with loadable-components

itmayziii profile image Tommy May III ・7 min read

Preface

I use Gatsby at work and in my personal projects because I believe it is the best tool out there right now in terms of efficiency as a developer and value added to my clients. The thing that keeps me using Gatsby is they really focus on performance and we all know performance matters when it comes to retaining users. As amazing as Gatsby is, it does not fully take performance off of our plate so we never have to worry about it again. As developers we should be testing the speed of our websites after every code and content change because no tool is going to handle every edge case in the world for us. Gatsby and websites in general are fast out of the box, but it is our job not to mess it up. In this post I want to share with you a case where Gatsby itself was not enough to handle our performance requirements and how we tackled the issue by constantly testing and making incremental changes.

The Performance Issue We Were Facing

At my work we primarily use 2 testing tools to measure our website performance.

  1. Lighthouse
  2. Web Page Test

In Lighthouse our website was scoring in the mid 70s (out of 100) and two of the things that were pointed out to improve were

  1. Reduce JavaScript execution time
  2. Minimize main-thread work

In Web Page Test our website had a very high time until the page was considered fully loaded and high load times are bad. I say "high" subjectively compared to the performance we were accustomed to seeing for the same exact website. An interesting thing about this Web Page Test tool is that you can block certain HTTP requests from happening which is a really handy way to test whether or not the presence of a certain request is the cause of performance issues. It turns out after blocking the gatsby generated javascript files on the page our website load time was cut in half!

The conclusion we drew from both of these testing tools was that the downloading, parsing, and execution time for our javascript scripts was too high.

Understanding Why Gatsby was Failing Us

In truth Gatsby did not fail us, but the out of the box solution that Gatsby provides for code splitting did. Gatsby provides a very in depth article to how they handle code splitting here so I'm not going to spend a lot of time going over it.

Dynamic Pages are the Real Issue

We are using Gatsby I believe in a very unique way where we have a custom CMS / design system feeding Gatsby data to create static pages with. Our CMS breaks up pages into different sections that we call modules.

website with modules outlines
The red lines separate what we call a module on our website and content writers in our CMS can compose a page of any of these modules which means on the Gatsby side we have to have code like this:

export default function Page ({pageFromCMS}) {
  return pageFromCMS.modules.map((module) => {
    const Module = findModuleComponent(module.id)
    return <Module module={module}/>
  })
}

This is not the real code but it very much illustrates what we are trying to accomplish. The idea is that we just want to take the modules that the CMS has for any given page and loop over them to dynamically put them on the page.

The issue with this code is that inside the function above called findModuleComponent we have to do something like:

import ModuleOne from './module-one'
import ModuleTwo from './module-two'

const modules = {
  'moduleOne': ModuleOne,
  'moduleTwo': ModuleTwo
}

export function findModuleComponent (moduleId) {
  if (!modules.hasOwnProperty(moduleId)) {
    throw new Error(`Module ${moduleId} does not exist`)
  }

  return modules[moduleId]
}

Do you spot the issue here and how it relates to code splitting from the title of this article?

Basic Understanding Code Splitting

If you have two import statements at the top of a file Gatsby / Webpack is going to bundle those imports into one javascript file during the build, and make something like https://www.dumpsters.com/component---src-templates-page-js-123eb4b151ebecfc1fda.js.

Bringing It All Together

Our requirements for our CMS to have any module on any page forces us to dynamically render the modules on the Gatsby side. In order to dynamically render any module we have to have a map of module names to react components which forces us to import all of our react components in the same file. The act of having all of these imports in the same file makes Gatsby/Webpack think that every module/import is needed on every single page so there is essentially no code splitting at all for our page specific code. This is a real problem because we could easily have 100 total modules and any given page probably only uses 10 of them so we have a lot of unneeded javascript on our pages.

Solving the Issue

We need a way to only import the modules that we need for any given page without sacrificing the dynamic nature of our CMS. Introducing dynamic imports mentioned by react and also Webpack. The issue with the dynamic imports right now is that it relies on React.lazy which does not support server side rendering. We absolutely need server side rendering, it is another big reason we chose to use Gatsby to statically render our HTML pages. React themselves acknowledges this limitation of React.lazy and they recommend using loadable components to address the issue for now.

Implementing Loadable Components in Gatsby

If you follow the documentation for loadable components you will probably quickly get confused when you get to the third step which is about how to set up the server side of your application. This step is confusing because Gatsby already takes care of these things for you! Gatsby itself is in charge of doing the server rendering and you will not need to override it to make loadable components work. Instead if you just follow the first 2 steps in the documentation then it will be enough to get started.

Step 1

You will need to use a custom babel plugin so you need to overwrite the Gatsby default one as described here.

.babelrc

{
  "plugins": [
    "@loadable/babel-plugin"
  ],
  "presets": [
    [
      "babel-preset-gatsby",
      {
        "targets": {
          "browsers": [">0.25%", "not dead"]
        }
      }
    ]
  ]
}

make sure to install @loadable/babel-plugin and babel-preset-gatsby

Step 2

You will need to add a custom webpack plugin.

gatsby-node.js

const LoadablePlugin = require('@loadable/webpack-plugin')
exports.onCreateWebpackConfig = ({ stage, getConfig, rules, loaders, plugins, actions }) => {
  actions.setWebpackConfig({
    plugins: [new LoadablePlugin()]
  })
}

again make sure to install @loadable/webpack-plugin and @loadable/component

Changing Our Code

Now that we have loadable components lets use its dynamic import abilities.

import loadable from '@loadable/component'

export default function Page ({pageFromCMS}) {
  return pageFromCMS.modules.map((module) => {
    const moduleFileName = findModuleFileName(module.id)
    const ModuleComponent = loadable(() => import(`../modules/${moduleFileName}`))
    return <ModuleComponent module={module}/>
  })
}

If we stopped now we would be most of the way there with code splitting happening at the module level and therefore we are not including a bunch of unneeded javascript on our pages. There is an issue with code like this though.
What will happen is:

  1. The static HTML will render to the user.
  2. React will hydrate itself onto the static HTML
  3. Your current DOM will be destroyed by React because it takes time for the dynamic import to resolve
  4. The modules will be added back to the page once the dynamic import actually loads the javascript file it needs.

This has a nasty effect of having content on the screen, it disappearing, and then reappearing which is a terrible UX. In order to solve this issue we did something clever/hackish (I'll let you decide). Essentially the loadable components library allows you to specify fallback content as a prop until it is able to load the javascript file. We don't want to use a loading spinner because that is still going to flash content, instead we know the HTML is already statically rendered on the page so we grab the HTML for that module with a document.querySelector and then specify it as the fallback content until the module's javascript has loaded.

This post is getting sort of long so I'm going to share will you some psuedo code / real code of the final solution.

import loadable from '@loadable/component'

return page.modules.map((module, index) => {
  const { moduleFileName, shouldLoadJavascript } = retrieveModulePath(module.id)
  if (isServer()) {
    // The server should always render the module so we get the static HTML.
    // RENDER YOUR MODULE
  }

  const wasUserPreviouslyOnSite = window.history.state
  const htmlEl = document.querySelector(`[data-module-index="${index.toString()}"]`)
  if (htmlEl && !shouldLoadJavascript && !wasUserPreviouslyOnSite) {
    // These modules do not require javascript to work, don't even load them
    // RENDER THE STATIC HTML ONLY HERE - something like <div dangerouslySetInnerHTML={{ __html: htmlEl.outerHTML }}></div>
  }

  const fallback = htmlEl && htmlEl.outerHTML ? <div dangerouslySetInnerHTML={{ __html: htmlEl.outerHTML }}></div> : null
  // RENDER THE MODULE NORMALLY HERE WITH THE FALLBACK HTML SPECIFIED
})

The above code accomplishes a lot of different things for us:

  1. Dynamic importing code for better code splitting
  2. Allows us to opt into not importing code at all for modules that don't need JS to work.
  3. Prevents any flash of content from happening.

Conclusion

Sometimes you have to go beyond what our tools offer us out of the box and that is okay. Gatsby is an excellent tool I plan on using for a long time but it needed some super powers added to it with loadable components. We saw a total of about 200KB of javascript removed from our site when we implemented something like this code and yes we have seen improvements in our page speed when using lighthouse and web page test.

I know I left some of the code above open ended but I really can't share much more since it is a company project. Feel free to reach out to me though if you have questions and I will guide you as much as I can without handing you the word for word solution.

Any follows on dev.to and twitter are always appreciated!

Cover Photo by José Alejandro Cuffia on Unsplash

Posted on by:

itmayziii profile

Tommy May III

@itmayziii

I’m just a developer that loves to help others.

Discussion

markdown guide
 

Hey Tommy,

Just wanted to thank you for sharing this solution with us, this is the only article I was able to find on gatsby addressing this issue. Was just wondering if you have a working example of that pseudo code you outlined at the end of your examples. Would you be willing to share it with us? :-)

Thank you again for writing this article and outlining your solution!

 

Hey Lilian,

I'm happy to share the real code now as we actually made the decision to remove React altogether for our simple site so this blog post quickly went out of date for us. I actually posted on Reddit a few days back with some open source plugins we created to make removing React easy reddit.com/r/gatsbyjs/comments/c6z...

The real code is below

  const modules = useMemo(() => {
    return page.modules.map((module, index) => {
      const { moduleFileName, shouldLoadJavascript } = getFromMap(appModules, module.id)
      if (isServer()) {
        // The server should always render the module so we get the static HTML.
        return renderModule(moduleFileName, module, index, isBusinessOpen)
      }

      const wasUserPreviouslyOnSite = window.history.state
      const htmlEl = document.querySelector(`[data-module-index="${index.toString()}"]`)
      if (htmlEl && !shouldLoadJavascript && !wasUserPreviouslyOnSite) {
        return staticRenderModule(index, htmlEl)
      }

      const fallback = htmlEl ? staticRenderModule(index, htmlEl) : null
      return renderModule(moduleFileName, module, index, fallback, isBusinessOpen)
    })

    // Render the module with the HTML currently rendered from the static HTML file without importing the javascript.
    function staticRenderModule (index, htmlEl) {
      return (<section key={index.toString()} dangerouslySetInnerHTML={{ __html: htmlEl.innerHTML }}/>)
    }

    // Render the module with all the javascript.
    function renderModule (modulePath, module, index, fallback = null, isBusinessOpen) {
      const ModuleComponent = loadable(() => import(`../modules/${modulePath}`))
      return (
        <ModuleContext.Provider key={index.toString()} value={{ lazyLoad: index > 1 }}>
          <ModuleComponent
            moduleIndex={index}
            fallback={fallback}
            module={module.data || {}} webPage={page}
            isBusinessOpen={isBusinessOpen}/>
        </ModuleContext.Provider>
      )
    }
  }, [page])

As you can see for each module we check whether or not the module should load javascript by checking the shouldLoadJavascript flag, if it does not load javascript then we just render the module statically. Here is our module map file that the getFromMap function is working on

export const appModules = {
  'scms-image-list': { moduleFileName: 'image-list', shouldLoadJavascript: false },
  'scms-footer-cta-bar': { moduleFileName: 'footer-cta-bar', shouldLoadJavascript: false },
  'scms-hero-section': { moduleFileName: 'hero/hero', shouldLoadJavascript: true },
  'scms-copy-left-image-right': { moduleFileName: 'copy-image', shouldLoadJavascript: false },
  'scms-full-width-table': { moduleFileName: 'full-width-table', shouldLoadJavascript: false },
  'scms-quick-links-menu': { moduleFileName: 'quick-links-menu', shouldLoadJavascript: false },
  'scms-call-to-action-bar': { moduleFileName: 'cta-bar', shouldLoadJavascript: false },
  'scms-dumpsters-difference': { moduleFileName: 'dumpsters-difference', shouldLoadJavascript: false },
  'scms-icon-list-short': { moduleFileName: 'icon-list-short-desc', shouldLoadJavascript: false },
  'scms-copy-icon-cards': { moduleFileName: 'copy-icon-cards', shouldLoadJavascript: false },
  'scms-icon-list-long': { moduleFileName: 'icon-list-long-desc', shouldLoadJavascript: false },
  'scms-locations': { moduleFileName: 'locations/locations', shouldLoadJavascript: false },
  'scms-full-width-copy': { moduleFileName: 'full-width-copy', shouldLoadJavascript: false },
  'scms-product-cards': { moduleFileName: 'product-cards-stacked/product-cards-stacked', shouldLoadJavascript: false },
  'scms-product-cards-main': { moduleFileName: 'product-cards-main/product-cards-main', shouldLoadJavascript: false },
  'scms-single-product-feature': { moduleFileName: 'single-product-feature/single-product-feature', shouldLoadJavascript: false },
  'scms-project-grid': { moduleFileName: 'project-grid/project-grid', shouldLoadJavascript: false },
  'scms-homepage-hero-section': { moduleFileName: 'homepage-hero/homepage-hero', shouldLoadJavascript: true },
  'scms-logo-banner': { moduleFileName: 'logo-banner', shouldLoadJavascript: true },
  'scms-testimonials': { moduleFileName: 'testimonials/testimonials', shouldLoadJavascript: true },
  'scms-two-column-copy-copy': { moduleFileName: 'copy-copy/copy-copy', shouldLoadJavascript: false },
  'scms-calculator': { moduleFileName: 'calculator', shouldLoadJavascript: true },
  'scms-price-search-cta': { moduleFileName: 'price-search-cta', shouldLoadJavascript: true },
  'scms-image-segment-list': { moduleFileName: 'image-segment-list', shouldLoadJavascript: false },
  'scms-two-column-copy-table': { moduleFileName: 'copy-table', shouldLoadJavascript: false },
  'scms-image-links-list': { moduleFileName: 'image-links-list/image-links-list', shouldLoadJavascript: false },
  'scms-two-column-copy-video': { moduleFileName: 'copy-video', shouldLoadJavascript: true },
  'scms-local-hero': { moduleFileName: 'local-hero/local-hero', shouldLoadJavascript: false },
  'scms-product-cards-local': { moduleFileName: 'local-product-card/local-product-card', shouldLoadJavascript: true },
  'scms-local-quick-links': { moduleFileName: 'local-quick-links/local-quick-links', shouldLoadJavascript: false },
  'scms-two-column-map-copy': { moduleFileName: 'map-copy', shouldLoadJavascript: false },
  'scms-local-accepted-materials': { moduleFileName: 'local-accepted-materials/local-accepted-materials', shouldLoadJavascript: false },
  'scms-tabbed-content': { moduleFileName: 'tabbed-content/tabbed-content', shouldLoadJavascript: true },
  'scms-lightbox-form': { moduleFileName: 'lightbox-form', shouldLoadJavascript: true }
}
 

Mate, you've made my day! :-)

Thank you so much for being so forthcoming and for your quick reply. There are so many issues or edge cases where I can get multiple sources addressing them, but for this one, your article is the only one that addressed exactly what I was looking for and in such a straightforward way.

I hope you won't stop sharing your knowledge and expertise. You've already saved me hours of headaches :-)

 

Can you please explain a bit more about he isServer() and "shouldLoadJS" Stuff?
I don't really got this in the Gatsby-Context. If the Page was build end served with gatsby, the Page become statically, right? So why is there an "isServer"-Request needed (and how do you test for "isServe"?)
Or is it to force to reload Component, if you are in gatsby develop mode?

And for shouldLoadJS ... what exactly the functionality of it? How do I decide if "javascript" is needed or not by this module. Does it means if you import additional JS inside this module?

 

I am really curious about how you're handling the lazyLoad: index > 1 part.
Could you enlighten me on this?

Sorry for the late reply, this lazyLoad: index > 1 is there mostly for an <Image/> component we have. We only wanted images to lazy load if they were later down the page, so the first module we had would never lazy load images.

 

This is exactly, almost word for word, the issue I’ve been trying to solve. Thanks so much for sharing in such great detail.

 

Just tried viewing your site on mobile. Not sure if it’s working

 

I don't really have a personal site at the moment, do you remember what site you were visiting?

 

Hey Tommy,

Thanks for the great article.

Where was your .babelrc file located? My gatsby projects only have this file in the .cache/tests/ directory, which is reset on each build