We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.
Ninety Seconds Is Not A Slow Page. It Is A Different Workflow.
Ninety seconds. That was how long one product's page took to finish loading on a local Vite dev server. Not a production build, not a CI job. The thing an engineer opens every morning, and again after every branch switch, and again after the dev server restarts because a config file changed.
At ninety seconds, people stop using the tool the way it was designed. They stop restarting the server when they should. They keep three stale tabs open so they never have to cold-load again. They start a load, open Slack, and come back four minutes later having forgotten what they were testing. None of this shows up in a dashboard, but it shows up in how carefully people test their own changes.
The easy explanation was the tool. Vite is unbundled in development, the app is large, so "Vite does not scale to big apps" sounded reasonable. It is also a convenient explanation, because it puts the problem outside the codebase and the fix on somebody else's roadmap.
It was not the bigger problem. The bigger problem was that opening one page asked the browser for the entire application. Every route in the product was imported eagerly at the top of the router, so loading a single screen pulled in every other screen, every one of their dependencies, and every file those dependencies touched. The import graph for that one page load was 11,516 modules.
After making the route imports lazy, the same page load needed 54 modules, and the finished-loading time dropped from 90 seconds to 10.54 seconds. The same analysis was then applied across six product areas.
This piece is about why that happens, why your production build never warns you about it, how to count modules in your own app in about five minutes, and the cases where lazy routes do not save you. I am not going to claim Vite is perfect for huge apps. The Vite team itself says it is not, and I will get to what they are doing about it. But the tool should be the second suspect, not the first.
In Development, Every Import Is A Network Request
To see why module count matters so much, you have to remember what Vite does differently from a bundler in development. The Vite docs put it plainly: source code is "served on-demand over native ESM", and Vite "transforms each file as it's requested." Dependencies from node_modules are pre-bundled once, so a library with hundreds of internal files arrives as a handful of requests. Your own code is not.
So the browser does the job a bundler would normally do: it walks the import graph. It loads your entry module, parses it, finds its import statements, requests those, parses them, finds their imports, and so on. Each hop is a real HTTP request to the dev server, which has to resolve the path, run the file through every relevant plugin, and send it back.
Three things make that expensive at scale:
- Discovery is layered. The browser cannot request a module's imports until it has received and parsed that module. A deep graph turns into a waterfall of round trips, even on localhost.
- Transforms are per file. TypeScript stripping, JSX, CSS modules, SVG-as-component plugins, any custom plugin you added. Each one runs for each file the browser asks for, the first time it asks.
- Connections are limited. By default the dev server speaks HTTP/1.1, and browsers open about six connections per host over HTTP/1.1. Thousands of requests queue behind each other. Vite's
server.httpsoption enables TLS and HTTP/2, which helps with the queuing, but it does not reduce the number of files to transform.
The Vite team is open about this limit. Their own "Why Vite" page says "exceptionally large codebases can experience slow page loads due to the high number of unbundled network requests." That sentence is the whole problem in one line. The question is whether your codebase is exceptionally large, or whether your page is just asking for far more of it than it needs.
A bundler hides your module count inside a few files. An unbundled dev server sends you an itemised bill.
How One Router File Pulls In The Whole Application
Here is the shape of a router file that almost every React app starts with. It is completely reasonable on day one, when there are four pages.
import Dashboard from "./pages/dashboard"; import Reports from "./pages/reports"; import Settings from "./pages/settings"; import Approvals from "./pages/approvals"; // ...and forty more, added one at a time over four years export const routes = [ { path: "/", element: <Dashboard /> }, { path: "/reports", element: <Reports /> }, { path: "/settings", element: <Settings /> }, { path: "/approvals", element: <Approvals /> }, ];
Every static import at the top of a module is a hard dependency. The browser must fetch and evaluate all of them before this module can run. So the router module depends on every page. Every page depends on its own components, hooks, API clients, form schemas, charts, tables, translations and utilities. Many of those depend on shared design-system components, and some of those depend on each other.
The result is that the graph reachable from the entry point is roughly the graph of the whole product. Open the dashboard, and you also load the reports page's charting code, the settings page's form library wrappers, and the approvals page's rich-text editor. You never render any of it. You still fetch, transform and evaluate all of it.
Nobody writes this on purpose. It grows. Each new page is a two-line change in the router, reviewed in thirty seconds. Each one adds a few hundred modules to every page load, and the cost is spread so thinly that no single pull request looks guilty. Four years later, the router file is the most expensive file in the repository and nobody has opened it in months.
An index.ts that re-exports everything in a folder turns "I need one button" into "load every component in this folder". The Vite performance guide specifically calls this out: barrel files force extra files to be fetched and transformed. If a page imports one helper from @/utils and that barrel re-exports sixty files, you pay for sixty.
Notice what the 11,516 to 54 ratio says. Fewer than half a percent of the modules in that load were needed to show the page. The rest were the cost of convenience in one file.
Why Production Never Warned You
If eager route imports are this expensive, why did nobody notice in production? Because production pays a different bill.
A production build runs the whole graph through a bundler once, on a CI machine, and emits a small number of chunks. Eleven thousand modules become a handful of JavaScript files. Users download more bytes than they need, which is a real cost, but they make a few requests, not eleven thousand. The minifier and compression shrink the bytes further. Your Lighthouse score might dip, but nothing like a 90-second wait appears anywhere.
| Cost of an eager router | Production build | Unbundled dev server |
|---|---|---|
| Requests per page load | A few chunks | One per module |
| Transform work | Once, in CI | Per file, on your laptop, on first request |
| Who notices | Users, as extra bytes | Every engineer, every cold load |
| Shows up in | Bundle size reports, LCP | Nothing, unless someone measures it |
That last row is the important one. Production performance has owners, budgets and dashboards. Local development performance usually has none. It is measured by complaint, and complaints are a terrible metric because people get used to things. An engineer who has waited 90 seconds every day for a year does not file a ticket about it. They just think that is what the app is like.
There is also a framing problem. When the dev server feels slow, people look at the dev server. They read about plugin overhead, try a different config, compare Vite versions, or argue for switching tools entirely. All of those can matter at the margin. But if the page is asking for two hundred times more code than it uses, no tool change will close that gap, because the tool is doing exactly what it was told to do.
Production performance has dashboards. Local development performance is measured by complaint, and people stop complaining.
Count Your Modules Before You Change Anything
The most useful thing in this whole article is a number you can get in five minutes: how many modules does one page load request in development? Most teams have never looked.
The quickest check is the DevTools Network panel. Start the dev server, open a page with the Network panel recording, filter to JS, and read the request count at the bottom. Keep "Disable cache" off, as the Vite docs recommend, and use a browser profile without extensions so they do not add noise.
For something you can repeat, put in a pull request description, or run in CI, a short Playwright script does the same thing:
import { chromium } from "playwright"; const url = process.argv[2] ?? "http://localhost:5173/"; const browser = await chromium.launch(); const page = await browser.newPage(); // In Vite dev, JS, TS and imported CSS all arrive as module scripts let modules = 0; page.on("request", (r) => { if (r.resourceType() === "script") modules++; }); const t0 = Date.now(); await page.goto(url, { waitUntil: "networkidle", timeout: 300_000 }); console.log({ url, modules, ms: Date.now() - t0 }); await browser.close();
Run it twice: once right after starting the dev server (cold, transforms not cached yet) and once again (warm). The cold number is what people feel after a restart or a branch switch. The warm number is what they feel on a refresh. The gap between them tells you how much of your time is transform work versus request overhead.
If the count looks too high, find out where it comes from before you fix anything:
- Sort the Network panel by the Initiator column and follow the chain upwards. If most chains run through your router file, you have found your problem.
- Run
vite --debug transformto log how long each file takes to transform, or use the communityvite-plugin-inspectplugin to see what each plugin does to each file. - Run
vite --profile, load the page, presspthen Enter, and open the saved.cpuprofilein a tool like speedscope to see where the dev server's own time goes.
"Dev server ready in 400ms" is the number Vite prints and the number people quote. It is not the number your engineers feel. Vite starts fast because it does almost nothing until the browser asks. The cost lands on the first page load, which is why the page is what you should time.
Make Routes Lazy, Then Check They Actually Are
The fix itself is not clever. Replace static imports in the router with dynamic import() calls, so each page's code is only requested when that route renders. With plain React, that is React.lazy plus a Suspense boundary:
import { lazy, Suspense } from "react"; // Nothing below is fetched until its route renders const Dashboard = lazy(() => import("./pages/dashboard")); const Reports = lazy(() => import("./pages/reports")); const Settings = lazy(() => import("./pages/settings")); const page = (el: React.ReactNode) => ( <Suspense fallback={<PageSkeleton />}>{el}</Suspense> ); export const routes = [ { path: "/", element: page(<Dashboard />) }, { path: "/reports", element: page(<Reports />) }, { path: "/settings", element: page(<Settings />) }, ];
Most routers have their own version of this. React Router's data routers accept a lazy function on a route, and file-based routers often split by route automatically. Use whatever your router expects. The principle is the same: the router file should know that a page exists without importing what is inside it.
Then comes the step that is easy to skip. Re-count. A dynamic import only keeps a module out of the initial graph if nothing else imports it statically. If a navigation config, a permissions map, a test helper re-exported through a barrel, or a "page registry" file still has import Reports from "./pages/reports", the page stays in the initial graph and the lazy wrapper does nothing useful.
Production builds tell you about this one, if you read the output. Vite prints a warning along the lines of "is dynamically imported by ... but also statically imported by ..., dynamic import will not move module into another chunk." In many large repositories that warning has been scrolling past in CI logs for months. It is worth grepping for.
In this case, confirming it gave a clean answer: 54 modules for the page load, down from 11,516, and 10.54 seconds to finish loading instead of 90. The page itself did not change. The engineers using it got back most of a minute and a half every time they opened it cold.
What Did Not Generalise, And What Lazy Routes Cost
I would be overselling this if I said "make your routes lazy and get 8x faster". The analysis was applied across six product areas, and the reason it was an analysis and not a find-and-replace is that the shape of the problem differs from app to app.
- Some apps are already lazy at the router. Their module count comes from a heavy shell instead: a layout that eagerly imports every modal, a global provider tree that pulls in half the design system, or a feature-flag module that imports the code for every flagged feature so it can decide which to show.
- Some pages are genuinely big. If one screen really does render a data grid, a chart library, a rich-text editor and a form builder, lazy routes do not shrink it. That page needs component-level splitting, or simply needs to be accepted as the heavy page.
- Shared code does not disappear. If every page imports the same giant barrel, every lazy route still pays for it. You moved the cost from "first load" to "every first navigation".
Lazy routes also cost something, in development and in production:
- Navigation in dev now has its own cold load. The first time you open a different route, its modules are fetched and transformed. On a well-split page that is a short pause. On a page that is still enormous, you have just moved the 90 seconds to a click.
- Loading states become a design problem. A
Suspensefallback is now part of every navigation. A spinner that flashes for 80ms on every click feels worse than no spinner. A skeleton that matches the page layout is usually the right answer. - Production gets a request on navigation. Users pay a chunk download the first time they visit each route. Prefetching likely next routes on hover or idle time usually covers this, but it is work someone has to do.
- Chunk-load failures become possible. After a deploy, an open tab may request a chunk that no longer exists. If the app has no handling for a failed dynamic import, users get a blank route instead of a page. Any app that code-splits needs a plan for that.
None of these are reasons not to do it. They are reasons to do it deliberately, with someone who owns the follow-up, instead of as a drive-by commit that turns every navigation into a flash of spinner.
What Vite Itself Offers, And Why It Comes Second
Blaming the tool was the wrong first move, but the tool does have levers, and it is worth knowing them once your own graph is sensible.
server.warmuppre-transforms files you list (for example, your app shell) when the server starts, so the first page load does not wait on them. The Vite docs say this "prevents transform waterfalls", and also warn against listing too much, which just moves the wait to startup.- HTTP/2 via
server.httpsremoves the six-connection queue. It does not reduce transform work and adds certificate setup for every engineer. - Cheaper transforms. The performance guide suggests plain CSS over preprocessors where possible, importing SVGs as URLs or strings instead of converting them into components, writing explicit import extensions to save resolution work, and keeping plugin hooks cheap.
- Bundled Dev Mode. Vite 8.1, released in June 2026, added an experimental mode that bundles in development the way production does. You turn it on with
--experimental-bundleorexperimental.bundledDev: true. The Vite team reported about 15x faster startup and 10x faster full reloads on a test app loading 10,000 React components, and Linear reported up to 3x faster cold-start rendering and 10x fewer network requests. It is experimental, and the announcement says third-party plugins may not work with it yet.
Bundled Dev Mode is the most interesting item on that list, because it attacks exactly the "one request per module" cost from section two. It is also exactly why measuring your graph first matters. A bundler in development makes 11,516 modules cheaper to deliver. It does not make them necessary. You still parse them, evaluate them, and run their top-level side effects on every load. And when you do something that invalidates the bundle, you pay to rebuild a graph that is mostly code the page never uses.
A faster delivery mechanism for code you do not need is still a delivery mechanism for code you do not need.
So my order is simple. First, make each page ask for what it uses. Then measure again. Then, if the honest number is still large, reach for warmup, HTTP/2 or bundled dev mode. In that order, the tool improvements land on a graph that deserves them.
Treat Local Load Time Like A Production Metric
The uncomfortable thing about this kind of fix is how easily it rots. One new page added with a static import in a shared file, one barrel that grows, one "quick" helper that imports a page component to reuse a type, and the graph creeps back up. Nobody will notice for months, for the same reason nobody noticed the first time.
So the lasting change is not the lazy router. It is deciding that local load time is a number someone owns. Some practical ways to do that:
- Put a module budget in CI. Start the dev server in a CI job, run the counting script against a few key routes, and fail or warn when a route crosses its budget. It is cheap, and it catches regressions in the pull request that causes them instead of a year later.
- Lint the router. A lint rule that forbids static imports from
pages/in route files, or from one page into another, encodes the decision so new engineers do not have to rediscover it. - Treat the Vite "dynamic import will not move module" warning as an error. It is almost always a lazy route that someone accidentally made eager again.
- Write down the before and after. "11,516 modules, 90 seconds" is a sentence that wins the argument the next time someone wants to add a convenient re-export. Without the number, the rule looks like taste.
This is the part I care about most as a frontend infrastructure engineer. Fixing one router is a good afternoon. Making a slow local loop visible, measurable and owned is what stops the next one. Most developer-experience problems are not hard to fix. They are hard to see, because they cost every engineer a little bit every day and none of them enough to raise their hand.
Suspect Your Graph Before You Suspect Your Tool
- Vite serves your source as native ESM in development and transforms each file when it is requested. Module count is the cost driver, and the Vite docs say so.
- A router that statically imports every page makes every page load the whole app. In this case that was 11,516 modules for one page load, where 54 were needed.
- Production builds hide the problem because bundling turns thousands of modules into a few files. Nothing in your production dashboards will point at it.
- Count modules per page load with the Network panel or a short Playwright script. Measure cold and warm. Time the page, not the server start.
- Lazy routes fixed this case: 90 seconds to 10.54. Re-count afterwards, because any static import of a page anywhere else silently cancels the split.
- Lazy routes move cost to navigation and add loading states and chunk-failure handling. Do them deliberately.
server.warmup, HTTP/2 and Vite 8.1's experimental Bundled Dev Mode help, but they come after the graph is fixed, not instead of it.
The best part of this fix was not the nearly 80 seconds. It was learning that the tool everybody wanted to replace had been doing exactly what the code asked. If your local app is slow, open the router file before you open a migration RFC. There is a decent chance the bottleneck is a list of imports that made perfect sense when there were four pages.
server.https and server.warmup), checked against the v8 docs in October 2026. Bundled Dev Mode details and the 10,000-component and Linear figures are from the Vite team's "Announcing Vite 8.1" post (23 June 2026). The six-connections-per-host figure refers to browsers' HTTP/1.1 connection limit. Cover photo by Koushik Pal on Unsplash.Related essays
- JavaScript · Browser Internals
setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.
The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.
- Browser Internals · Performance
Chrome Stopped Running Your unload Handler. If Your Analytics Still Use It, Your Numbers Already Changed.
Since Chrome 154, unload handlers are off by default on every Chrome page load - they register without error and never run. Reproduced in Chromium: what it does to the Back button, which data quietly went missing during the seven-month rollout, and the pagehide pattern to use instead.
- HTTP Caching · Frontend Infrastructure
Cache-Control: no-cache Does Not Mean Do Not Cache. And No Header At All Is Worse.
no-cache stores the response and revalidates it every time - no-store is the one that refuses to store. And an index.html with no Cache-Control at all is still cached, for a period the browser works out from Last-Modified. Reproduced in Chromium, with the two-rule setup I would use.