One bundle was 700KB. The criteria I set while splitting it with Vite manualChunks
How I decided what goes into which chunk when splitting a 700KB bundle with Vite manualChunks, and when I use lazy and when I don't.
#Vite #Bundle optimization #React #Code Splitting #Performance
I built my portfolio site and the JS came out as one lump. A single file of over 700KB had React, framer-motion, lottie and react-icons all inside it. The problem was that the first screen doesn't need all of that . lottie is only used on the error screen, and the chatbot only shows up when the user presses a button. But everything gets downloaded even if the user does nothing. This post is a record of how I decided what goes where while splitting it up. Why wasn't automatic chunking enough? Vite (Rollup, to be exact) also splits chunks on its own. When it meets a dynamic import, it cuts at that point. But libraries that are imported statically all get bundled into one lump . That's how my code was. import { motion } from 'framer-motion'; // static import import Lottie from 'lottie-react'; // static import -> both go into the main bundle So I did two things together. What really isn't used I cut off with a dynamic import, and what is used but is different in nature I separated with manualChunks . Criterion 1. Separate things with different cache lifetimes This was the most important criterion. My code changes on every deploy, but React changes once every few months . If the two are in one file, the user downloads React again even when I deploy a single typo fix. // vite.config.ts manualChunks(id) { if (!id.includes('node_modules')) return; if ( id.includes('/react/') || id.includes('/react-dom/') || id.includes('/react-router') || id.includes('/react-error-boundary') || id.includes('/@remix-run/') || id.includes('/react-is/') || id.includes('/scheduler/') ) { return 'react-core'; } } The reason I put react-router in with it is that they get upgraded together and stay unchanged together . @remix-run, react-is and scheduler are things react-router and react-dom pull in, so they have to be grouped together to keep the chunks from referencing each other. At first I didn't include these, and I got a strange graph where the react-core chunk referenced vendor. When splitting dependencies, you have to look at what comes along with them too . Criterion 2. Pull out things that are heavy but needed late lottie was like that. It's only used for the error screen animation, yet it's 317KB . There's no reason to download it on the first screen. if (id.includes('lottie-web') || id.includes('lottie-react')) return 'lottie'; if (id.includes('react-icons')) return 'react-icons'; if (id.includes('framer-motion')) return 'vendor'; I pulled out react-icons separately too. Icons only ever get added, never removed, so if they sit with my code, the main bundle hash changes every time I add one icon. Criterion 3. Things that only appear when the user clicks go fully lazy Splitting chunks and not downloading at all are different things. manualChunks only splits the files, and in the end everything still gets downloaded. To really not download something, you have to use a dynamic import. const ChatBot = lazy(() = import('../../component/ChatBot/ChatBot')); Suspense fallback={null} ChatBot / /Suspense The chatbot only opens when the user presses a button. Making it lazy turned it into a separate 4.49KB chunk , and if nobody presses the button it isn't downloaded at all. On the other hand, there are also things I didn't make lazy . With something like the landing cards or the About panel, making them lazy means a loading spinner shows up once after the click. The file is small, but it feels slower. The criterion for lazy had to be "might not be seen", not "heavy" . Make it lazy might not be seen + heavy e.g. chatbot, error screen animation Don't make lazy almost everyone sees it e.g. first-screen cards, main panels (splitting them means a wait on every click) The result After splitting, the build output came out like this. The numbers below are from measuring the dist folder on the server again while revising this post. My code has grown by about 16KB since I first wrote it, and the other chunks are the same. dist/assets/ChatBot.js 5.10 kB gzip: 2.58 kB - not downloaded unless clicked dist/assets/react-icons.js 12.09 kB gzip: 4.55 kB dist/assets/index.css 38.00 kB gzip: 8.50 kB dist/assets/index.js 111.38 kB gzip: 26.45 kB - my code dist/assets/vendor.js 142.31 kB gzip: 47.37 kB - framer-motion dist/assets/react-core.js 161.94 kB gzip: 52.62 kB - almost never changes dist/assets/lottie.js 317.44 kB gzip: 81.89 kB - error screen only The numbers make it clear. lottie alone is about half of the total . Keeping it in main versus pulling it out makes a big difference to the first load. And my code (index.js) is only 26KB gzipped . Even if I deploy every day, what the user downloads again is that 26KB, and the rest, close to 190KB, comes out of the browser cache. Before the split, they downloaded everything again each time, even for a single typo fix. One thing I didn't do I thought about making framer-motion lazy and dropped the idea. That's because it's used right away for the card entrance animation on the first screen . If it were lazy, the animation would start only after waiting for the animation library, and that's worse than not doing it at all. 142KB does feel like a waste, but animation is the core of this site, so I decided to pay the price. Looking at the numbers again While writing this I looked at the build output again and something was odd. lottie is 317KB, and the only thing that uses it is one error screen. I'm keeping a chunk that takes up half of the whole bundle for a screen a user might see once in their life, if ever. Since it's lazy, it doesn't get downloaded. But I never thought about whether the error screen needs a Lottie animation in the first place. A single SVG might have done the job. That's the first thing I should look at next time I work on it. Asking whether the library is needed at all should have come before asking how to split the chunks.