I Finally Stopped Ignoring Vite’s Chunk Size Warning
How code-splitting turned my 664 KB monster bundle into something I’m not embarrassed by

So last night I was wrapping up a build of my calorie-tracking PWA, it has an AI chat feature, a Razorpay subscription flow, the works - and I ran npm run build like I always do before shipping. And there it was, staring at me:
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
My entire app was being packed into one JavaScript file. 664 KB of it.
I’d seen this warning before, and honestly, in past projects, I’d done the lazy thing - ignore it. Warning gone. Problem "solved." But this time I actually sat down and fixed it properly, and I want to walk you through what I did, because once it clicked, it felt obvious in a way I wish someone had explained to me earlier.
First - why does this warning even exist?
Before we talk about fixing anything, let’s understand what’s actually happening.
When you build a React app with Vite, it bundles all your JavaScript into output files called chunks. By default, if you’re just using regular import statements at the top of your files, Vite pulls everything into one big chunk.
And “everything” really means everything. Every component. Every library. Every modal, every page, every utility function, all of it, is shipped to the browser the moment someone visits your site, even if they never click on half those features.
That’s the actual problem. Not the warning. The warning is just Vite saying, “Hey, this is going to be slow for your users.”
The tempting (wrong) fix
Vite’s warning literally suggests this option:
// vite.config.js
export default {
build: {
chunkSizeWarningLimit: 1000 // just bump it higher
}
}
One line. Warning gone. Ship it.
I’m not going to pretend I haven’t done this. But here’s the thing - you haven’t fixed anything. You’ve just told Vite to stop complaining about it. Your users are still downloading 664 KB before they can see your app. You’ve just stopped knowing about it.
The actual fix: lazy loading with React.lazy and Suspense
React has two built-in tools for this:
- React.lazy() — lets you tell React "don't load this component until it's actually needed"
- <Suspense> — lets you define what to show (a spinner, a blank screen, whatever) while that component is loading
Together, they allow you to split your app into smaller pieces that only download when a user actually needs them. This is called code splitting.
Let’s walk through how I applied it at two different levels.
Level 1: Split by Route
My app had two main routes — a /auth login screen and a /dashboard. Here's roughly what my App.jsx looked like before:
// App.jsx — BEFORE (the bad version)
import Login from './Login';
import Dashboard from './Dashboard';
import ProfileOnboarding from './ProfileOnboarding';
These are regular imports. Which means the moment your app loads, all three of these components - and every single thing they import - get bundled together and sent to the browser.
Think about that for a second. A brand new user visiting your login page is downloading the entire dashboard, the entire onboarding flow, and everything those pull in. They haven’t even logged in yet.
Here’s the fix:
// App.jsx — AFTER (the good version)
import { Suspense, lazy } from 'react';
const Login = lazy(() => import('./Login'));
const Dashboard = lazy(() => import('./Dashboard'));
const ProfileOnboarding = lazy(() => import('./ProfileOnboarding'));
The only difference is lazy(() => import('./Login')) instead of import Login from './Login'. But that small change is huge - it tells Vite: "create a separate file for Login, and only fetch it when someone actually tries to render it."
You also need to wrap your routes in a <Suspense> boundary, which defines what to show while a chunk is loading:
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/auth" element={<Login onLogin={handleGoogleSignIn} />} />
<Route path="/dashboard" element={
<ProtectedRoute user={user} loading={loading}>
<ProfileGate user={user}>
<Dashboard user={user} session={session} />
</ProfileGate>
</ProtectedRoute>
} />
</Routes>
</Suspense>One <Suspense> wrapping all your routes is enough — it'll catch any lazy component inside it, no matter how deep it is in the tree.
Level 2: Split by User Interaction (Modals)
This one was less obvious to me at first, but it made an even bigger difference.
My Dashboard.jsx had four modals — a food logger, a history viewer, an AI chatbot, and a subscription/payment modal. Here's how they were imported:
// Dashboard.jsx — BEFORE
import LogFoodModal from './LogFoodModal';
import HistoryModal from './HistoryModal';
import AIChatbot from './AIChatbot';
import SubscriptionModal from './SubscriptionModal';
And they were rendered conditionally, based on whether the user had clicked to open them:
{showLogFood && <LogFoodModal ... />}
{showAIChat && <AIChatbot ... />}
{showHistory && <HistoryModal ... />}
{showSubscriptionModal && <SubscriptionModal ... />}Here’s the insight: these components only exist on screen when a user clicks something. By default, they’re all false. Nobody sees them on first load.
But those plain import statements at the top? They don't care. They load everything eagerly, before the user has clicked anything, before they've even looked at the dashboard.
AIChatbot alone drags in react-markdown (to render formatted AI responses). SubscriptionModal drags in react-razorpay. These are hefty libraries that have zero relevance to someone who just wants to log their lunch.
The fix is the same pattern - just applied to modals:
// Dashboard.jsx — AFTER
import { Suspense, lazy } from 'react';
const LogFoodModal = lazy(() => import('./LogFoodModal'));
const HistoryModal = lazy(() => import('./HistoryModal'));
const AIChatbot = lazy(() => import('./AIChatbot'));
const SubscriptionModal = lazy(() => import('./SubscriptionModal'));
And wrap groups of related modals in their own <Suspense>:
<Suspense fallback={null}>
{showLogFood && <LogFoodModal ... />}
{showAIChat && <AIChatbot ... />}
</Suspense>The Before and After
Same app. Same features. Only the import style changed.
Before:
dist/assets/index-CGN0UgEE.js 664.51 kB │ gzip: 198.65 kB
After:
dist/assets/index-d8kI22xf.js 228.76 kB │ gzip: 73.23 kB
dist/assets/supabaseClient-CMexn7p-.js 186.78 kB │ gzip: 48.62 kB
dist/assets/AIChatbot-CfoI9C00.js 120.21 kB │ gzip: 36.65 kB
dist/assets/Dashboard-BJi6qewu.js 52.70 kB │ gzip: 19.91 kB
dist/assets/App-BnmqSyiG.js 31.80 kB │ gzip: 9.86 kB
dist/assets/ProfileOnboarding-CpFO9yHU.js 11.99 kB │ gzip: 3.77 kB
dist/assets/LogFoodModal-COB9EX80.js 11.84 kB │ gzip: 3.47 kB
dist/assets/HistoryModal-55zCDZKE.js 3.99 kB │ gzip: 1.55 kB
dist/assets/Login-DT6HWvDN.js 3.41 kB │ gzip: 1.37 kB
dist/assets/SubscriptionModal-BIveRquC.js 3.34 kB │ gzip: 1.01 kB
✓ built in 225ms
No warning. And more importantly - real improvements for real users:
- Someone who just logs in and tracks food never downloads AIChatbot (120 KB) or SubscriptionModal. Those bytes simply don't travel over the wire.
- The dashboard itself went from being bundled with everything to a clean 52.7 KB - just the code needed to render the calorie ring and meal log that every user sees first.
- react-markdown and react-razorpay now only load when someone actually opens the AI chat or the payment modal. Not on page load. Not for everyone.
What Should You Not Lazy Load?
Not everything is a candidate. Here’s what I intentionally left as regular imports:
Shared vendor code (React, React Router, etc.) - Every page needs React. Splitting it further would just make your browser download the same React runtime multiple times under different filenames. Vite handles this automatically by putting shared code in a “vendor” chunk.
Tiny inline components - I have a couple of small error modals defined directly inside Dashboard.jsx as a few lines of JSX. There's no separate file to lazy-load in the first place. Adding Suspense around them would add complexity without splitting off any actual bytes.
The Mental Model, in One Sentence
Lazy-load anything that isn’t needed to render the very first thing a user sees.
More specifically, look for two patterns in your code:
- Route boundaries - If two components are never on screen at the same time because they’re on different routes, neither needs to be in the same bundle as the other.
- Click/interaction boundaries - If a component only appears after someone clicks a button, the button can ship in the main bundle. The component it opens doesn’t have to.
If you found this helpful, I write about building real products with React, NodeJS, and a lot of trial and error. Follow along.