Your Users Download 1.5MB of JavaScript to See a Landing Page. We Fixed It With an If Statement.

Your users are downloading 1.5 megabytes of JavaScript to see your landing page.
They don't need the dashboard code. They don't need the admin panel. They don't need the settings page, the notification system, the rich text editor, or the 47 other components that only authenticated users will ever see.
They need the landing page. That's 400 kilobytes.
Next.js solves this with automatic route-based code splitting. Remix solves it with nested route loaders. Astro solves it with islands. All excellent solutions that come packaged with a framework-sized dependency and a framework-shaped set of constraints.
We solved it with three build calls and an if statement.
I – The Problem We Couldn't Ignore
Before we implemented code splitting, the mentoring platform shipped a single JavaScript bundle to every visitor. The total was 1.82 megabytes — about 480 kilobytes gzipped.
Inside that bundle lived everything. React runtime. Router. Query library. Ten public page components. Three auth page components. Nine private dashboard components. A calendar. A messaging system. A rich profile editor. An entire real-time session billing interface with SSE streaming.
A user visiting the homepage to browse mentors downloaded all of it. Of the 1.82 megabytes they received, they'd use about 320 kilobytes.
Over 80 percent of the JavaScript was for features they couldn't even access without signing in.
This isn't a theoretical concern. On a 3G connection — common in many of the 40 countries we serve — 1.82 megabytes of JavaScript takes six to eight seconds to download. The page renders as static HTML instantly thanks to our SSG, but it doesn't become interactive until the JavaScript loads, parses, and hydrates.
During those six to eight seconds, buttons don't work. Forms don't submit. The page feels broken.
II – Three States, Three Bundles
The key insight is that our application has three distinct user states, each with different JavaScript requirements.
Public. Not authenticated. Browsing mentors, reading the FAQ, checking pricing. Needs page components, internationalization, and a data fetching layer.
Auth. In the authentication flow. Entering their email for a magic link, verifying. Needs auth forms, validation, and a slim API client.
Private. Authenticated and using the product. Dashboard, messaging, sessions, profile editing, calendar. Needs everything.
These states map cleanly to URL paths. Anything under a locale prefix — slash-en, slash-es, slash-pt — is public. Anything under slash-auth is the authentication flow. Anything under slash-dashboard is the private application.
The server knows which state the user is in before a single byte of JavaScript loads. This is the crucial difference from client-side code splitting with lazy loading. The decision happens at request time on the server, not at runtime in the browser.
III – Three Build Calls, 650 Milliseconds
At server startup, we run three bundler invocations in parallel. One for the public entry point. One for the auth entry point. One for the private entry point.
Each entry point imports only the components and dependencies its user state requires.
The public entry point pulls in React, the router, the query library, and ten page components. It weighs in at about 398 kilobytes.
The auth entry point pulls in React, the router, and three form components. It doesn't include the query library at all — auth pages don't need data fetching, they submit forms and redirect. 148 kilobytes.
The private entry point pulls in everything — React, router, query library, nine dashboard page components, the SSE billing interface, the calendar, the messaging system. It also uses lazy loading internally so that each page within the dashboard loads only when navigated to. 1.48 megabytes total, but the initial chunk is only about 380 kilobytes.
All three bundles build in parallel. Total build time: 650 milliseconds.
The public bundle is 3.7 times smaller than the private bundle. That's the entire point of this exercise.
IV – The If Statement That Does the Work
The server-side bundle selection is almost embarrassingly simple.
If the URL path starts with slash-auth, serve the auth bundle. If it starts with slash-dashboard, serve the private bundle. Everything else gets the public bundle.
Three conditions. That's the entirety of the routing logic.
For public pages — the ones pre-rendered by our SSG — the HTML already contains the correct bundle reference. The server looks up the pre-rendered page from its in-memory map and returns it. Done.
For auth and private pages — which aren't pre-rendered — the server generates a minimal HTML shell with the appropriate bundle reference and an inline CSS loading skeleton. The skeleton gives the user instant visual feedback while the JavaScript loads. No external CSS file to fetch. No flash of unstyled content.
The skeleton appears on first paint. React replaces it when hydration completes. The transition is seamless.
V – Cross-Bundle Navigation: The Hard Problem
Here's the question everyone asks: what happens when a user clicks "Sign In" on a public page?
They need to go from a public route to an auth route. But the public bundle doesn't contain auth components. React Router in the public bundle won't match the auth URL.
The answer is a full page navigation. Not a client-side route change. A regular anchor tag. A real HTTP request. The browser loads a new page with a new bundle.
Similarly, after authentication completes, the user is redirected to the dashboard with a full page navigation. The browser loads the private bundle fresh.
Is this a worse user experience than instant client-side navigation? Slightly. The transition takes about 300 milliseconds instead of 50.
But it happens at most twice per session — sign in, sign out. And the trade-off — one megabyte less JavaScript on every public page load — is overwhelmingly worth it.
Navigation within a bundle remains instant and client-side. Going from the mentors list to a specific mentor profile is a React Router transition with no page reload and no new bundle download. Both routes live in the public bundle.
VI – Two Levels of Splitting
The private bundle uses a second level of code splitting on top of the server-side selection.
React's lazy loading splits each dashboard page into a separate chunk. When an authenticated user first navigates to slash-dashboard, they load the private bundle's entry chunk — about 380 kilobytes — plus just the Dashboard page component.
The Calendar component, at 210 kilobytes, doesn't load until the user actually navigates to the calendar page. Same for Messages, Profile Editor, and every other heavy component.
Server-level splitting decides which bundle to load. Client-level splitting decides which pages within that bundle to load. Two layers working together, giving each user exactly the code they need and nothing more.
VII – Content Hashing and Aggressive Caching
The bundler outputs files with content hashes in their filenames. When the content changes, the filename changes.
This lets us set the most aggressive possible cache headers. We tell browsers the file is immutable — it will never change at this URL. If the content is different next time, it will have a different URL.
This eliminates conditional requests entirely. No "has this file changed?" round trips. The browser either has the file cached or it downloads it fresh. Nothing in between.
For returning visitors, the public bundle loads from cache in effectively zero time. The page goes from static HTML to fully interactive almost instantly.
Struggling with bundle size or load performance on your web app? These are exactly the kinds of hands-on architectural problems I work through in mentoring sessions. Book one at mentoring.oakoliver.com, or explore my projects at oakoliver.com.
VIII – The Numbers That Matter
Here are real Lighthouse scores from production. Not synthetic benchmarks. Real pages visited by real users.
The homepage — the most important page, top of the funnel, the one that determines whether someone stays or bounces.
Time to Interactive dropped from 3.4 seconds to 1.6 seconds. A 53 percent improvement. The page looks identical either way — SSG HTML renders instantly regardless. But with the single bundle, buttons weren't responding for over three seconds. With the public bundle, they respond in under two.
Total Blocking Time fell by 64 percent. JavaScript size dropped by 78 percent — from 1.82 megabytes to 398 kilobytes. Performance score jumped from 71 to 94.
The auth page went from a 2.8-second Time to Interactive to 0.6 seconds. JavaScript dropped by 92 percent. Performance score: 99. The sign-in page is practically instant — 148 kilobytes of JavaScript, no data fetching, no hydration. Just a form.
The dashboard improvement is more modest — about 12 percent faster, 19 percent less JavaScript. The private bundle is still large. But the dashboard is behind authentication. Users who reach it have already signed in, are on a stable connection, and are actively using the product.
We optimized for the widest part of the funnel — public pages where every visitor arrives — and accepted slower loads at the narrow end where committed users are willing to wait.
IX – Why Each Bundle Includes Its Own React
You might wonder: doesn't each bundle include its own copy of React? Wouldn't a shared chunk be more efficient?
Yes, each bundle includes React. That's intentional.
No user loads more than one bundle per session. A public visitor loads the public bundle with its copy of React. An authenticated user loads the private bundle with its copy. Nobody loads both simultaneously.
If we shared React across bundles, we'd need a shared chunk that loads on every page — meaning the public visitor downloads the shared chunk AND the public chunk. Two HTTP requests. More cache invalidation complexity. Similar total size.
The simpler model: each bundle is self-contained, each user downloads exactly one, and there's no shared chunk to coordinate. Less complexity. Fewer cache considerations. The same or better total transfer size.
X – Development Experience
In development, a file watcher monitors source directories and recompiles all three bundles when anything changes. With minification disabled, the rebuild takes about 280 milliseconds. Combined with the watcher's 100-millisecond debounce, the feedback loop from save to updated bundle is under 400 milliseconds.
This is comparable to dedicated dev servers for our codebase size. A hot module replacement setup would be faster for single-component updates, but our approach rebuilds everything — which means we never have stale module state. No more "did my change actually take effect, or is HMR confused?"
The build is fast enough that full rebuilds are painless. And full rebuilds mean full correctness.
XI – When This Breaks Down
Honesty time. This approach has limits.
More than four or five bundles and the routing logic gets complex enough to want a framework's automatic splitting.
Shared authenticated state on public pages — if public pages need to show "Welcome, John" to logged-in users, you need auth logic in the public bundle, blurring the boundary.
Rapid route additions — if you're adding new routes weekly, manually assigning them to bundles is friction.
Large teams — if frontend developers don't control the server, they can't modify bundle selection logic. Frameworks abstract this away.
But for a small team with a well-defined application, stable route structure, and full-stack control? Three bundles and an if statement will outperform any framework's automatic splitting, because you know your application better than any heuristic can.
What's your JavaScript bundle size on your landing page? Have you measured how much of it is for features your anonymous visitors will never touch?
The answer might surprise you. And the fix might be simpler than you think.
– Antonio