25.33% Reduction in First Load JS with NextJS Dynamic Imports

A play by play in successfully reducing first load JS bundle size by 25% with code splitting via dynamic imports, and getting all NextJS pages into the green zone.

Kevin Wang


NextJS is nice for so many reasons, and you'd be doing yourself a disservice by not using it in 2021 for web development. But that's besides the point of this post.

My goal with this post is to document my own personal success story and exploratory efforts with NextJS and code splitting for improving page load time. Hopefully this will be both high-level enough to communicate benefits to say, a hands-off engineering manager or a backend developer, and in-depth enough to point a frontend developer in the right direction if they were to explore this area themselves.

NextJS Baked-in Observability

Here's a quote from the NextJS docs.

next build creates an optimized production build of your application. The output displays information about each route. The first load is colored green, yellow, or red. Aim for green for performant applications.

When you use Next CLI, this little bit of feedback on its own is immensely valueable for monitoring your own bundle size. Every time you build your application, the NextJS CLI outputs your First Load JS for each of your pages. From this, you can get a rough estimate of how well you're doing in terms of page speed.

I'm unable to replicate the CLI output colors exactly as they would appear, so I'm substituting them with green/yellow squares here: 🟩 / 🟨

Here's my initial next build output.

The one highlighted line is what triggered onset of this article.

Additional Observability

I needed a more granular breakdown of my bundle size so I used @next/bundle-analyzer which also provided a visualizer.

  1. Install: yarn add @next/bundle-analyzer

  2. Update config:

  3. Run: ANALYZE=true next build

  4. The browser should automatically open the bundle analyzer:

    bundle analyzer ui

From a quick glance at the visualizer, I made an educated guess that the <Layout/> component, which is reused by all my pages, would be the spot for potential improvements.

layout bundle size

Removing 3rd Party Components

Looking at the tree structure of the Layout component,

The first thing I noticed was 3rd party components that I was pulling in from @material-ui into a nested Header component. I made yet another guess that replacing those with homemade components could shave down on some bytes.

Replacing a full-fledged, battle-tested, whatever-you-want-to-call-it solution from a large open source library, such as Material UI, could potentially be a large undertaking — there's a reason why those solutions exist. The complexity will vary on a case-by-case basis, and it's possible that the speed gains,  if any at all, may not actually be worth the engineering effort.

Without diving into specifics, this particular component refactor happened to be a light lift — it took about 5 minutes — but it didn't result in significant improvements to first load JS.

Result 1

Neglible improvements — this probably only shaved off < 1 kB

Dynamic Imports

My next stop was dynamic imports (TC39 proposal).

Dynamic Header

I tried dynamically importing the entire Header component to see what that could change:

I was under the impression that this would dynamically render the header, which would increase culmulative layout shift (cls), which would hurt Chrome's lighthouse accessibility scores.

I checked lighthouse before and after and didn't notice any regressions in CLS, so this looked ok.

Result 2

-17 kB from First Load JS!

You might be thinking, "Woah, thats magic!", and then questioning, "Wait, where do those extra kB's go?"... If you're familiar with the Law of Conservation of Mass, the bytes-equivalent applies here.

"Bytes are neither created nor destroyed. They're simply redistributed."

3 new "chunks" were created:

css/9966a83c0eaa2c20cbb1.css (3.11 kB) chunks/8eb64b0ea41808d73389c1b6dbe939bfae9ed001.fb132b.js (10 kB) chunks/ed20a0fe1cf8573e8666bc854e61e3f8486924a2.c14d5a.js (4.36 kB)

The math adds up too. The 17 kB removed from First Load JS gets created as 3 new chunks, totalling 17.47 kB.

Moving on, I applied the same dynamic import to the footer.

Result 3

Another -17 kB from First Load JS!

My dynamic pages — /[year]/[month]/[day]/[slug] — were no longer the largest pages.

Dynamic Banner

At this point, all my pages were already green, but I wanted to test if this final dynamic import would have any benefits.

All that appears to be happening now is some JS is being redistributed.

  • Page /: 108 kB → 107 kB
  • First Load JS shared by all: 80.5 kB → 81.4 kB

Result 4

Negligible improvements.

Conclusion

For demonstration purposes, I'm mashing together the outputs of the next build command, from before and after all my exploratory work.

Assuming my before-and-after comparisons makes sense, here are my conclusions.

The average first load JS decreased by 17.87%, from 119.36 kB to 98.03 kB

...and...

The first load JS for the largest page — /[year]/[month]/[day]/[slug], which is also a dynamic route for N-number of statically generated pages — decreased by 25.33%, from 133 kB to 99.3 kB

This brought all the pages into NextJS's green zone. 🎉

And for fun, here's a shot of the in-browser lighthouse score. (Although https://web.dev/measure/ is a more accurate assessment.)

lighthouse scores

So, why is this good? Faster page load times means better lighthouse scores which means better search engine ranking which means more traffic which means greater potential for new customer acquisition (if you're a business) which means more money! (Just assumptions here.)

🎉 💯 💯 💯 💯 🎉

So should I dynamically import everything?

No. Just because you can doesn't mean you should.

Max Rozen made a great point on Reddit that wasn't initially clear to me or even mentioned in the official docs.

Using too many dynamic imports actually reduces performance. Instead of a single request, you now have the overhead of several requests, for content the user wanted to see anyway.

Dynamic imports are best used for things like modals — you don't know if the user is going to open your modal, so you dynamic import it, and it doesn't get downloaded until the user clicks.