By the second visit the JavaScript is cached, so the visit that counts is the first: a new customer on a mid-range phone and a slow connection, waiting on one file that holds the whole app. How to reduce JavaScript bundle size starts with reading that file, then 3 cuts in order: remove, split by route, lazy load.
What code splitting is, and what lazy loading adds
Code splitting is the bundler cutting the app’s output into separate chunks at each dynamic import, so the browser downloads a chunk only when something asks for it. Lazy loading is the app deciding when to ask: on a route change, a click, or a scroll.
Bundle size is one of the measured checks in web performance optimization for one app. The mechanism underneath is the import() expression. MDN’s page on dynamic import describes it as loading a module “asynchronously and dynamically”, and notes that “dynamic imports are only evaluated when needed”. MDN’s glossary defines code splitting as separate bundles “that can be loaded independently of each other”, so the app can “load other bundles on demand”.
The difference between code splitting and lazy loading is who acts and when: the bundler cuts at build time, the app asks at run time, and each needs the other. Splitting without lazy loading changes nothing the user feels, because every chunk still downloads at the start; lazy loading without splitting has nothing to fetch, because the code is already in the one big file. In a builder-made single-page app the default is often a single entry chunk that holds every page, including admin screens a new visitor never opens. That is my reading of how these apps ship, and the real finding further down shows one case.
What a bundler does, and which one your AI-built app is already on
A JavaScript module bundler follows every import from the entry file, drops what it can prove is unused, and writes the files a browser downloads. The build script in package.json names the one your app runs on, and none of the cuts on this page depends on which it is.
JavaScript bundling is three jobs, in this order. First, the bundler walks the imports: webpack’s concepts page says it “internally builds a dependency graph from one or more entry points”. Second, it removes dead code. MDN calls that tree shaking and says it “relies on the import and export statements” to see what is used, which is why a package that ships no ES modules is hard to trim. Third, it writes the output: webpack “combines every module your project needs into one or more bundles”. So what a bundler does for your bundle size depends on what your code imports and where it calls import().
| How the app was built | The bundler underneath | Where sizes show after a build |
|---|---|---|
| Lovable project on the older template | React + Vite: Rollup before Vite 8, Rolldown from Vite 8 | The vite build terminal output |
| Lovable project on TanStack Start (the default for new projects since May 13, 2026; June 22, 2026 for Enterprise) | TanStack Start, which “supports Vite and Rsbuild”; the build script names which | That tool’s build output |
| Any other Vite project | Rolldown on Vite 8 and later (build.rolldownOptions); Rollup before | vite build output, with gzip sizes reported by default |
| Next.js 16 and later | Turbopack by default; webpack with next build --webpack | Not in next build since 16.0; next experimental-analyze from 16.1 |
| Next.js 15 and earlier | webpack by default (Turbopack builds were beta from 15.5) | The size and First Load JS metrics in next build |
| Create React App (deprecated) | webpack | The hashed files in build/static; its docs point to source-map-explorer |
Angular, ng new default | @angular/build:application, which builds with esbuild | The Initial Total line of the build output summary |
| Plain script tags, no build step | None | The DevTools Network tab only |
To find yours in a minute, open package.json and read the build script: vite build, next build or ng build names the tool. On Angular, the builder of the build target in angular.json names the bundler. For a Lovable project, Lovable’s TanStack Start page says projects built before the switch use “React + Vite, the stack Lovable used before TanStack Start”. You do not need to change bundlers to do anything below.
What goes wrong without it
A huge JavaScript bundle is slow on mobile for two reasons: the phone has to download it, and then it has to parse and run it on a slower processor. MDN’s performance guide says that “by default, JavaScript parsing and execution are render-blocking”, and that JavaScript “can significantly impact download times, rendering performance, and CPU and battery usage”. The laptop hides both costs, which is why the app feels fine to the person who built it; that last part is my reading, not MDN’s.
| What the user sees | Why | Which cut fixes it |
|---|---|---|
| A blank screen or a spinner on the first visit | The entry chunk carries every page, the chart library, the editor and the admin area | Remove, then split by route |
| The page paints, but taps lag for a while | The main thread is still parsing and running script | Lazy load heavy features |
| Fine on the laptop, slow on a phone | The same bytes meet a slower CPU | All three cuts, then a test with CPU throttling |
| Returning users re-download everything after each deploy | One big file changes on every deploy, so its hashed name changes too | Split by route, so a deploy changes only the chunks it touched |
The table is my own summary. When the first page load takes too long, look at the top row first: a blank screen while the entry chunk arrives, because the whole app, every library and the admin area ship to the landing page and nothing told the bundler to cut. The last row is about caching: Create React App’s docs put a hash of the file contents in every file name under build/static, and name long-term caching as a potential advantage of splitting vendor code from application code.
Among the apps I audited in June and July 2026, one Q&A app shipped as one JavaScript file with the build’s own chunk splitting switched off. My reading of that case: a single file for the whole app is a build setting someone chose, and a setting can be chosen again.
The three Core Web Vitals, their thresholds and the tools that measure them are in Core Web Vitals for AI-built apps. If the slow part is the server and not the script, a smaller bundle will not help, and the right test is a load test like a k6 load testing example.
How to reduce JavaScript bundle size: measure, remove, split, lazy load
Reducing JavaScript bundle size is 6 steps with the same measurement after each: record the baseline, name the three largest things in the entry chunk, remove or replace them, split by route, lazy load heavy features, and set a size budget in CI so it cannot grow back.
To minimize bundle size without losing track of what helped, make one change, rebuild, and take the same measurement again before the next change. The order matters: removing code shrinks every later number, and splitting a bundle that still carries dead weight only spreads the weight around. Every tool behavior below comes from that tool’s own documentation; the order and the working rules are mine.
- 01 Record the baseline: the built size of the entry chunk and each route chunk, compressed and uncompressed, plus a throttled first load of the landing route with the cache disabled.
- 02 Open an analyzer on the production build and write down the three largest things inside the entry chunk.
- 03 Remove or replace each of those three, rebuild, and measure again.
- 04 Split by route: every page except the landing route loads through a dynamic import. Rebuild and measure again.
- 05 Lazy load heavy features (editors, charts, maps, exports, chat widgets, the admin area) on interaction or visibility. Rebuild and measure again.
- 06 Set a size budget in CI just above the new sizes, so a pull request that adds a heavy import fails.
Check bundle size after build
Bundle size after a build is read in two places: the build output, where Vite reports gzip sizes and warns on chunks over 500 kB uncompressed, and an analyzer treemap, where area is bytes. Next.js 16 no longer prints sizes, so there the analyzer is the record. My working rule: read compressed size for the network, uncompressed for the CPU.
Start in the browser, with nothing installed. Open Chrome DevTools, go to the Network tab, tick Disable cache, click the JS filter and reload. DevTools “lists the total size of transferred and loaded (uncompressed) resources in the status bar at the bottom of the Network panel”. Write both numbers down before you touch anything.
Then read the build. In Vite’s build guide and its options, build.reportCompressedSize defaults to true and turns on “gzip-compressed size reporting”, and build.chunkSizeWarningLimit defaults to 500 kB, “compared against the uncompressed chunk size as the JavaScript size itself is related to the execution time”. Next.js is different: “Next.js 16 removes the size and First Load JS metrics from the next build output”. On Next.js 16 and later the numbers come from the analyzer or the Network tab; only Next.js 15 and earlier print the size and First Load JS metrics.
For the picture, use an analyzer. To check bundle size in a React app built with Vite, add rollup-plugin-visualizer to the plugins list in vite.config; it draws a treemap by default, and its gzipSize option (off by default) adds compressed sizes. On Next.js, the docs name two tools: the Next.js Bundle Analyzer for Turbopack builds (next experimental-analyze, version 16.1 and later) and @next/bundle-analyzer for webpack builds. For anything with source maps, source-map-explorer “determines which file each byte in your minified code came from” and shows a treemap.
# Vite: with visualizer() in vite.config plugins, the build writes the treemap file
npm install --save-dev rollup-plugin-visualizer && npx vite build
# Next.js 16.1+ (Turbopack): save the analysis to .next/diagnostics/analyze
npx next experimental-analyze --output
# Any build with source maps (Vite: set build.sourcemap, default false)
npm install -g source-map-explorer && source-map-explorer dist/assets/*.js
Copy .next/diagnostics/analyze or the treemap file somewhere safe before each change, so the before and after sit side by side.
Remove before you split: the heaviest dependencies
Removing a dependency deletes bytes, and splitting only moves them later, so removal comes first. The 5 usual finds are a whole library imported for three functions, an oversized date or chart package, duplicate versions, development code, and needless polyfills.
| What the treemap shows | The usual cause | The fix |
|---|---|---|
| One large block for a utility or icon library the code uses in a few places | A whole-library import, or a package with no ES modules, so tree shaking cannot cut it | Import the named members, or the per-icon path |
| A date, chart or editor library far larger than its use on the page | The first package that worked when the feature was built | Compare candidates on a package-size tool, then swap |
| The same package twice | One dependency pins an older version | Upgrade or dedupe so one copy remains |
| Mock data, a devtools panel or a seed script | Imported by accident from app code | Delete the import, or keep it out of the production entry |
| Polyfill code for old browsers | A legacy plugin, or polyfill imports | Check @vitejs/plugin-legacy and any polyfill imports; the build target sets the syntax level |
The table is mine; the mechanisms behind it are documented. Tree shaking “relies on the import and export statements”, so a library that ships no ES modules keeps its unused parts. source-map-explorer’s own README shows a bundle with “two copies of React in the bundle (perhaps because of out-of-date dependencies)”. Vite’s build guide says that “by default, Vite only handles syntax transforms and does not cover polyfills”, and that legacy browsers “can be supported via @vitejs/plugin-legacy”. If the treemap shows polyfills, look at that plugin and at any polyfill imports first.
Before swapping a library, look up both candidates on Bundlephobia, a bundle size calculator for npm packages that shows a package’s minified and gzipped size “before it becomes a part of your bundle”. Read which entry it measured: for react-dom it reports the package’s main entry and returns a package-not-found error for react-dom/client, which is where a React app imports createRoot from. Sizes change with each release, so look them up the day you decide.
AI builders tend to add a package for each prompt and rarely take one out, which is my reading of how these projects grow. So add one more pass: for each dependency in package.json, search the source for an import of it. A package nothing imports is a removal that costs nothing to test.
Lazy load JavaScript routes
Lazy loading routes means each page’s code arrives when a visitor navigates to it, which in my reading is often the largest single cut in a single-page app. Every framework in the table below supports it, and every lazy boundary needs a fallback and an error boundary for chunks that fail to load.
Lazy-loaded routes are routes whose code sits in its own chunk and loads on the first visit to that route. Vue Router’s docs put the reason plainly: it is “more efficient if we can split each route’s components into separate chunks, and only load them when the route is visited”. Every page a visitor does not open stops shipping to them, which is why I start here after removal.
| Framework or router | The route-level mechanism | Mode or condition |
|---|---|---|
React with React Router in declarative mode (<Routes>) | React’s lazy(() => import(...)) inside <Suspense> | Declare each lazy component at the top level of a module |
React Router in data mode (createBrowserRouter) | The route object’s lazy property | ”Most properties can be lazily imported to reduce the initial bundle size” |
| Next.js App Router | Server Components are code split automatically; next/dynamic for Client Components | Lazy loading applies to Client Components |
| Vue Router | component: () => import('./views/UserDetails.vue') | Fetched on first entry to the route, then cached |
| Angular | loadComponent for one component, loadChildren for child routes | loadComponent loads when the route would become active; loadChildren loads child routes during route matching |
| TanStack Router (TanStack Start) | autoCodeSplitting: true in the router’s bundler plugin | File-based routing with a supported bundler only |
The sources for each row: React’s lazy reference, React Router’s route object docs, Next.js’s bundling guide with its lazy loading page, Vue Router’s lazy loading guide, Angular’s routing guide and TanStack Router’s code-splitting guide. To lazy load JavaScript routes with React Router in data mode, the block below is the shape.
// React Router 8.4.0 docs, data mode (createBrowserRouter), not declarative <Routes>
const router = createBrowserRouter([
{ path: '/', Component: Landing, ErrorBoundary: ReloadMessage },
{ path: '/admin', ErrorBoundary: ReloadMessage, lazy: async () => {
const { default: Component } = await import('./admin/Dashboard');
return { Component };
} },
]);
ReloadMessage is your own component: a short line that says the page needs a refresh, and a reload button. Every lazy boundary needs a fallback while the chunk loads and an error path for when it does not, because a deploy can replace the chunk a long-open tab still asks for. React’s docs say that if the import fails, “React will throw the rejection reason for the nearest Error Boundary to handle”. React Router documents route error boundaries for framework and data mode and marks them “Not available with Declarative”, so in declarative mode the error boundary is a React error boundary component wrapped around the Suspense. Check 4 under “How to verify it” proves that a failed chunk reaches it on your build. Vite also emits a vite:preloadError event when a dynamic import fails, and its docs’ example reloads the page. My working rule is a button, not an automatic reload, because an automatic reload loops when the chunk is really gone.
Prefetch the next likely route where the router offers it. React Router’s Link has a prefetch option whose “intent” value “prefetches when the user hovers or focuses the link”, with no prefetching by default; its docs mark the option available in framework mode and not available in data or declarative mode. Next.js prefetches in production: “As each <Link> enters the viewport, Next.js prefetches the route behind it”.
Picture a founder’s builder-made React app with one route table that imports every page up front, including an admin dashboard that pulls in a charting library only staff ever use. The build puts all of it in the entry chunk, so a new visitor on the landing page downloads the charting library too; once the admin route is wrapped in lazy with a dynamic import(), the bundler splits it into its own chunk that loads only when someone opens the admin route. The built sizes per route, before and after, are the evidence, and my take on the lesson: a route a visitor never opens should cost that visitor nothing.
Lazy load heavy features, and what never to lazy load
Heavy features load on interaction or visibility: editors, charts, maps, exports, chat widgets and the admin area. Nothing in the first viewport is lazy loaded, because deferring the largest visible element makes the measured first load worse, not better.
The same import() works below the route level. Load the rich-text or code editor when the user clicks Edit, the chart when its container scrolls into view, the map when its tab opens, the PDF or spreadsheet export when someone presses Export, and the AI chat widget when its launcher is clicked. MDN’s own advice is the same idea: “not load the JavaScript at all until an event occurs when it is needed”. Third-party scripts, such as analytics and support chat, load after the page is interactive.
What never to lazy load, as my working rule: anything in the first viewport, the largest above-the-fold element, the login form on the login page, and the CSS the first screen needs. Google’s lazy-loading guidance says the same for visible content: “Don’t add lazy-loading to content that is likely to be immediately visible when a user opens a page.” Images are the other half of page weight; work through the image optimization checklist for websites after these cuts.
A size budget in CI so it does not grow back
A budget is a number in the repository that fails the build when the entry chunk or a route chunk grows past it. The open-source size-limit tool “checks every commit on CI, calculates the real cost of your JS for end-users and throws an error if the cost exceeds the limit”, and its README suggests adding it to CI “to know if a pull request adds a massive dependency”. Angular has budgets built in: a budgets section in angular.json, where “the builder warns or reports an error” when part of the app “reaches or exceeds a boundary size that you set”.
size-limit’s README suggests a limit 25% above the current size. My working rule is tighter: set it a little above the size you reached after the cuts, so the next heavy import fails the check instead of eating the headroom. The check belongs in the same pipeline as your tests, alongside the other CI/CD best practices. Hashed file names plus long cache lifetimes, part of caching strategies for web applications, then mean returning users re-download only the chunks that changed.
Vite, esbuild, SWC or webpack: does changing the bundler make the app faster for users?
Changing the bundler mostly changes build time, not what users download. esbuild calls itself a bundler, while SWC’s docs say its own bundling will be dropped in v2 in favor of SWC-based bundlers such as Turbopack and Rspack. Bundle size is decided by what the code imports and where it splits, and those fixes carry over to any bundler.
My plain answer: a different bundler mostly changes how long the build and the dev server take, which is the developer’s time. What a new visitor downloads is decided by the imports and the split points, and every cut above works the same with whichever tool you pick. Here is what each tool says it is, in its own docs.
| Tool | What its own docs say it is | Who uses it underneath |
|---|---|---|
| webpack | ”a static module bundler for modern JavaScript applications”; latest release v5.111.1 (2026-09-18) | Next.js with --webpack; Create React App; Angular’s browser builder |
| Rollup | ”a module bundler for JavaScript which compiles small pieces of code into something larger and more complex” | Vite’s production builds before Rolldown |
| Rolldown | ”Rust-based bundler for JavaScript with Rollup-compatible API and esbuild feature parity” | Vite 8 and later |
| esbuild | ”An extremely fast bundler for the web” | Angular’s default application builder |
| SWC | ”an extensible Rust-based platform for the next generation of fast developer tools”; its bundling “will be dropped in v2" | "used by tools like Next.js, Parcel, and Deno” |
| Vite | ”a build tool” with a dev server and “a build command that bundles your code with Rolldown” | Lovable’s older template; Nuxt, SvelteKit, Astro, React Router and other frameworks |
| Turbopack | ”an incremental bundler optimized for JavaScript and TypeScript, written in Rust, and built into Next.js” | Next.js 16 by default |
Vite’s own history shows how much this layer moves: it “originally relied on two separate tools under the hood: esbuild for fast compilation during development, and Rollup for thorough optimization in production builds”, and Rolldown was built “to unify both into a single bundler”. A Vite alternative, for an app that already works, means a framework with its own build such as Next.js, or Parcel, Rspack or plain esbuild; I name them, I do not rank them.
My working rule for when a switch is worth it: builds so slow they block releases, or a toolchain that is no longer maintained, as Create React App’s own site now says it is deprecated. Never as the first fix for a slow first load.
How to verify it
A bundle cut is verified two ways: compare built sizes per route before and after, then load the landing route and one lazy route with the cache disabled under a slow mobile network and CPU profile, keeping both traces as the record.
Each check below is one you can fail, with the evidence to keep. The network presets come from Chrome DevTools’ network reference, which lists “fast 4G, slow 4G, or 3G” in the Throttling drop-down.
- 01 The record: one row per route, with entry JavaScript before and after, compressed and uncompressed, from the build output or, on Next.js 16 and later, from the analyzer. Evidence: the table below, filled in.
- 02 The route test: in DevTools tick Disable cache, pick a slow mobile preset in the Throttling menu, and set CPU throttling in the Performance panel capture settings. Load the landing route and note when it becomes usable. Confirm the lazy route chunk is not in the landing route first load, then navigate and confirm it arrives. A documented prefetch (hover or focus with the React Router intent prefetch in framework mode, a link entering the viewport in Next.js) may fetch it just before the click; that passes. Evidence: the two traces.
- 03 The Coverage panel shows less unused JavaScript on the first load after the cuts than before. Evidence: before and after screenshots.
- 04 Block one lazy route chunk URL in the Request conditions drawer before loading the app, then navigate to that route and confirm the error boundary shows the reload message, not a white screen. Evidence: the screenshot.
- 05 Open a pull request that adds a heavy import and confirm the CI budget fails it. Evidence: the failing run.
- 06 Open the app on a real mid-range phone on mobile data. Evidence: a note of the device, the network and the date.
The phone check is there because the laptop cannot stand in for it: Chrome’s docs say “DevTools can’t truly simulate the CPUs of mobile devices”, and offer a Calibrate option under Capture settings to get closer. The Coverage panel reports “the total used and unused bytes of CSS and Javascript resources”, which is the number check 3 compares.
| Route | Entry JS before (compressed / uncompressed) | Entry JS after (compressed / uncompressed) | Throttled time to usable, before / after |
|---|---|---|---|
/ (landing) | 410 kB / 1.4 MB | 160 kB / 520 kB | 9.0 s / 4.1 s |
/pricing | 410 kB / 1.4 MB | 175 kB / 560 kB | 8.8 s / 4.3 s |
/admin (lazy) | in the entry chunk | 140 kB / 480 kB, own chunk | not on first load |
The numbers in that table are illustrative, made up to show the layout, not measured on any app. The full mobile Lighthouse run with targets is its own job: how to run a Lighthouse audit.
On the Production Hardening Sprint, deliverable 9.3 is verified this way: compare built bundle sizes and test route loading with representative network conditions.
Where the sprint does this
Deliverable 9.3 of the Production Hardening Sprint, bundle optimization and lazy loading, is where we reduce unnecessary frontend code and load routes or features when needed; its verification line is the one quoted at the end of “How to verify it”. Deliverable 9.4 is where we optimize image dimensions, formats, compression, and delivery while preserving useful quality, and 9.5 is where we run Lighthouse on representative pages, implement performance and accessibility improvements, and deliver a passing result against recorded acceptance targets. The production readiness report, deliverable 13.1, delivers the result for every scope item, the work completed, and its verification evidence; it is verified by accounting for all 123 IDs, keeping failures visible until resolved and explaining genuine non-applicable items. If you are weighing a bundler switch: your app’s current framework and hosting setup are our starting point, and we refactor or replace components where the production work requires it. The deliverables are listed in the published scope.
Common questions about bundle size and lazy loading
Is Webpack still used in 2026?
Yes. webpack’s latest release on its GitHub releases page is v5.111.1, published on 2026-09-18, and Next.js 16 still builds with webpack when you pass --webpack. Create React App apps run on it too, though Create React App itself is deprecated.
Is lazy loading bad for SEO?
No, as long as content loads whenever it is visible in the viewport and the loading does not rely on a scroll or click. Google’s guidance says lazy-loading methods should not “rely on user actions, such as scrolling or clicking, to load content”, because “Google Search does not interact with your page”. The details are in Google’s guidance on lazy-loaded content.
What are the downsides of using lazy loading?
There are three, and each has a fix. The first use of a lazy feature waits for its chunk, which prefetching on hover or viewport softens. A chunk can fail to load after a deploy replaced it, which an error boundary with a reload button handles. A fallback with no fixed size can shift the layout when the real content arrives, which a placeholder of the same size prevents. The list is mine; the fixes are the ones in the lazy loading sections above.
Is ESBuild faster than vite?
No like-for-like answer exists, because they are different layers, and either way the comparison is about build speed, not what users download. esbuild is a bundler; Vite is a build tool whose docs say it originally used esbuild in development and Rollup in production, and now bundles with Rolldown. esbuild’s own benchmark compares it with parcel 2, rollup 4 plus terser and webpack 5, not with Vite, and none of those numbers changes the size of the files your visitors get.
How big is the React bundle?
No single number answers it. A package-size lookup for react-dom measures the package’s small main entry, not the react-dom/client renderer an app imports (Bundlephobia, checked 2026-10-04). The honest number is your own: build a blank React app with your bundler and read the entry chunk in the build output, as in check 1 above. The app’s own imports often outweigh React itself; that is my reading, not a measurement.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase