MonkeyLoader All articles
Performance

Your Bundle Is Too Big: Splitting a UK SaaS Front-End by Route Without a Framework Rewrite

MonkeyLoader
Your Bundle Is Too Big: Splitting a UK SaaS Front-End by Route Without a Framework Rewrite

Photo: Orange County Archives from Orange County, California, United States of America, CC BY 2.0, via Wikimedia Commons

There is a specific sort of dread that comes with opening the network tab on your own product and watching a 1.4MB JavaScript bundle crawl down a throttled 4G connection. We hit it in spring 2026 on a booking platform for veterinary practices — a small UK SaaS with around 4,000 monthly active users, two developers, and absolutely no appetite for a framework migration.

So we did the boring thing instead: we split the app by route. No rewrite, no new tooling beyond what Vite already gave us, roughly two days of work. This is the playbook, including the bits that caught us out.

Open the network tab before you open the docs

Before you touch anything, get the receipt. Hard reload with the cache disabled, throttle to Fast 4G, and watch what actually lands. Then run a visualiser — npx vite-bundle-visualizer if you are on Vite, webpack-bundle-analyzer if you are not — and look at the treemap with a slightly suspicious eye.

The usual suspects in small UK SaaS codebases are remarkably consistent: a full icon library imported from one index file, moment with every locale, a charting package pulled in for one screen, polyfills for browsers you stopped supporting in 2022, and the same date picker bundled three times because two components imported it differently. In our case a single rich-text editor and a charting library accounted for nearly half the payload, and neither was needed on the login screen — which is where every single session starts.

Also worth a look: the Coverage panel in DevTools. It tells you what percentage of your shipped JS actually executed on load. Ours was 31%. That is a lot of dead weight crossing a train passenger's mobile connection between Paddington and Reading.

Pick the routes that actually cost you

Not every route deserves splitting. Look at your own analytics and rank routes by sessions, then by weight. You are hunting for the mismatch: heavy and rare is the cheap win, heavy and common is the real problem.

On our app the reports screen carried the charting library, was visited in about 8% of sessions, and made up roughly 40% of the bundle. The dashboard and bookings screens, visited by everyone, were comparatively light. Splitting reports out alone cut the initial download by more than a third.

Wire the routes to dynamic imports

If you already have a router, this is mostly mechanical. The pattern is a map from path to loader function, with the shell rendering immediately and the route module arriving a moment later.

// router.js
const routes = {
  '/login': () => import('./routes/login.js'),
  '/app/dashboard': () => import('./routes/dashboard.js'),
  '/app/bookings': () => import('./routes/bookings.js'),
  '/app/reports': () => import('./routes/reports.js'),
};

export async function navigate(path) {
  const loader = routes[path] ?? routes['/app/dashboard'];
  try {
    const mod = await loader();
    render(mod.default);
  } catch (err) {
    handleChunkError(err, path);
  }
}

Then add a cheap prefetch. When a pointer hovers a nav link for more than about 120ms, fire the loader. On a touch device, prefetch the two most likely next routes once the current one has settled.

link.addEventListener('pointerenter', () => {
  setTimeout(() => routes[link.dataset.route]?.(), 120);
});

That second snippet is the difference between a split that feels instant and one that feels broken, especially on the 4G connections a lot of your users are actually on.

Loading states, and why a spinner is usually wrong

When the split does cost you a network hop, do not show a spinner in the middle of an empty page. Render the shell — sidebar, header, page title — straight away, and put a skeleton where the content goes. Match the skeleton's dimensions to the real content so you do not swap a fast load for a layout shift, which is its own Core Web Vitals problem.

If the chunk arrives in under about 150ms, most people would rather see nothing at all than a flash of grey. A short delay before showing the skeleton is a small kindness.

The error case everybody forgets

Here is the one that bit us in production. You deploy at 4pm on a Thursday. A user has the old HTML cached. That HTML references reports-a1b2c3.js, which no longer exists on the CDN, because the new build called it reports-d4e5f6.js. The dynamic import fails and the user gets a blank screen and a support email.

Catch it. Retry once, check navigator.onLine, and if it is genuinely a missing chunk, show a polite reload prompt rather than a stack trace. Vite gives you an event for this:

window.addEventListener('vite:preloadError', () => {
  showReloadBanner();
});

If you are on a custom bundler, wire the same behaviour into your import catch block. The banner should say something human, along the lines of: we have shipped an update, reload to carry on.

Measure it in the field, not just in the lab

Lighthouse is a fine smoke test and a terrible source of truth. Install the web-vitals package, send LCP, INP and CLS to your own collection endpoint, and segment by device and connection type. Given the UK's patchy mobile coverage outside the big cities, your field data on a mid-range Android will look nothing like your MacBook's lab score.

Worth checking CrUX for competitors in your niche too — it is free, it is field data, and it will tell you very quickly whether a 2.1 second LCP actually puts you ahead in your market.

What it bought us

Initial transferred JavaScript went from 1.4MB to about 310KB. LCP on a throttled mid-range Android dropped from 3.4s to 1.9s. INP on the bookings form went from around 280ms to 140ms, mostly because we were no longer parsing chart code that would never run. Total effort: two developer-days plus a day of testing.

Three ways teams muck this up

Splitting per component rather than per route, which turns one download into a waterfall of six. Duplicating a shared vendor chunk into every route bundle, which you fix with manualChunks or the equivalent. And splitting the route while still fetching all the data the route might need, which just moves the problem from bytes to requests — split the data fetching too, or you have done half a job.

Start with the receipt, split the heavy routes, handle the failure path, then measure in the field. It is not glamorous, but it is the best performance return per hour you will get this quarter.

All Articles

Related Articles

Peak Season With Two Pairs of Hands: A Loader Checklist for Small UK Retail Teams

Peak Season With Two Pairs of Hands: A Loader Checklist for Small UK Retail Teams

Cron, Clocks and the Missing Hour: Scheduling Loaders for Europe/London

Cron, Clocks and the Missing Hour: Scheduling Loaders for Europe/London

The Two-Second Illusion: Making Council Pages Feel Quick on a Wobbly Connection

The Two-Second Illusion: Making Council Pages Feel Quick on a Wobbly Connection