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.
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.
Page Size First Load JS
┌ ● / 3.71 kB 128 kB 🟩
├ /_app 0 B 79.2 kB 🟩
├ ● /[year]/[month]/[day]/[slug] 8.9 kB 133 kB 🟨
├ └ css/57d6f1c73619f1a44c66.css 3.69 kB
├ ├ /2021/03/01/infrastructure-as-code-to-save-time
├ ├ /2021/03/06/jamstack-ci-cd-with-lerna-next-js-cdk-and-github-actions
├ └ /2021/03/15/reduce-first-load-js
├ ○ /404 1.22 kB 122 kB 🟩
├ ● /how-to 3.83 kB 128 kB 🟩
└ ○ /me 5.17 kB 126 kB 🟩
└ css/bcc0ab3f4a6e0a5d3b14.css 960 B
+ First Load JS shared by all 79.2 kB 🟩
├ chunks/12ba7591d43e0be4bd8f2d0b114e9dee332ebc4c.c6c005.js 12.7 kB
├ chunks/commons.894139.js 15.4 kB
├ chunks/framework.e25a57.js 42.3 kB
├ chunks/main.e88387.js 6.59 kB
├ chunks/pages/_app.9c4434.js 658 B
├ chunks/webpack.648aa6.js 1.58 kB
└ css/5bddb30097b44559415a.css 3.81 kB
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
(ISR) incremental static regeneration (uses revalidate in getStaticProps)
The one highlighted line is what triggered onset of this article.
The browser should automatically open the bundle analyzer:
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.
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.
src/components/Header.tsx
- import AppBar from "@material-ui/core/AppBar";- import Toolbar from "@material-ui/core/Toolbar";+ // ...Own implementation for AppBar+ // ...Own implementation for ToolBar import useScrollTrigger from "@material-ui/core/useScrollTrigger";
import useMediaQuery from "@material-ui/core/useMediaQuery";
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.
I tried dynamically importing the entire Header component to see what that could change:
src/components/Layout.tsx
+ import dynamic from "next/dynamic"; import Banner from "components/Banner";
- import Header from "components/Header";+ const Header = dynamic(() => import("components/Header")); import Footer from "components/Footer";
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.
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."
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.)
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.)
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.