Code Splitting Vue Router Routes With defineAsyncComponent

A Vue 3 application with twelve routes and a single 620 KB chunk is almost always the same bug, visible in one line of the router configuration:

// router.js — every route component is imported eagerly
import Dashboard from '../views/Dashboard.vue';
import Settings from '../views/Settings.vue';
import Reports from '../views/Reports.vue';

const routes = [
  { path: '/dashboard', component: Dashboard },
  { path: '/settings', component: Settings },
  { path: '/reports', component: Reports },
];

Every view is a static import, so the router module reaches all of them and the bundler puts them in one chunk. The router is doing exactly what it was asked; the splitting decision was made — accidentally — at the import statement.

The general technique is covered in implementing route-level code splitting in SPAs. This page is the Vue-specific detail: which of the two lazy mechanisms applies where, and why choosing the wrong one produces a working but visibly worse navigation.

Root cause: two lazy mechanisms with different owners

Vue 3 offers two ways to defer a component, and they belong to different layers.

A lazy route record gives the router a function returning a promise. The router awaits that promise as part of the navigation, before the view renders. Loading state is the navigation’s concern, and any transition you have configured covers it.

defineAsyncComponent wraps a loader into a component that manages its own pending, error, and timeout states, rendering placeholders inside whatever template uses it.

Using defineAsyncComponent for a route component puts both mechanisms in play: the router resolves the wrapper immediately, renders it, and the wrapper then starts its own load with its own loading component. The result is an extra visible state transition inside a navigation that should have been atomic.

Where the Loading State Lives With a lazy route record the router fetches the chunk during navigation and renders the view once. With defineAsyncComponent the router renders a wrapper immediately, which then shows its own loading component before swapping in the view. Lazy route record — one state change navigation starts router awaits the chunk view renders, complete defineAsyncComponent on a route — three state changes navigation starts wrapper renders loading component view swaps in Both fetch the same bytes — only the second one shows the user the seams the router's transition cannot cover a loading state it does not own

The fix: lazy route records for views

// router.js — Vue 3.4+ with Vue Router 4, built by Vite 5+
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  {
    path: '/dashboard',
    // A function returning a promise: the router awaits it during navigation.
    component: () => import('../views/Dashboard.vue'),
  },
  {
    path: '/settings',
    component: () => import('../views/Settings.vue'),
    // Guards belong on the record, not inside the component: this runs
    // BEFORE the chunk is requested, so an unauthorised user costs 0 bytes.
    beforeEnter: (to, from) => (useAuth().canManage ? true : { path: '/' }),
  },
  {
    path: '/reports',
    component: () => import('../views/Reports.vue'),
  },
];

<svg viewBox="16 7 688 169" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A guard on the route record rejecting before the chunk request, versus an in-component guard rejecting after it" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;">
  <title>Guard Placement Decides the Bytes</title>
  <desc>A route-record guard runs before the chunk is requested; an in-component guard cannot run until the chunk has been downloaded and evaluated.</desc>
  <rect x="16" y="7" width="688" height="169" fill="#FAF7F0"/>
  <text x="176" y="26" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="12" font-weight="600">Guard on the record — 0 KB</text>
  <rect x="24" y="44" width="130" height="34" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="89" y="66" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">navigate</text>
  <rect x="170" y="44" width="150" height="34" rx="4" fill="none" stroke="#2a9e5e" stroke-width="1.5"/><text x="245" y="66" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">guard rejects</text>
  <line x1="154" y1="61" x2="170" y2="61" stroke="currentColor" stroke-width="1.5" marker-end="url(#vg-arrow)"/>
  <text x="176" y="110" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">no chunk request is ever made</text>
  <text x="540" y="26" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="12" font-weight="600">Guard inside the component — full chunk</text>
  <rect x="392" y="44" width="90" height="34" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="437" y="66" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">navigate</text>
  <rect x="496" y="44" width="90" height="34" rx="4" fill="none" stroke="#e8552a" stroke-width="1.5"/><text x="541" y="66" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">fetch 84 KB</text>
  <rect x="600" y="44" width="96" height="34" rx="4" fill="none" stroke="#e8552a" stroke-width="1.5"/><text x="648" y="66" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">then reject</text>
  <line x1="482" y1="61" x2="496" y2="61" stroke="currentColor" stroke-width="1.5" marker-end="url(#vg-arrow)"/>
  <line x1="586" y1="61" x2="600" y2="61" stroke="currentColor" stroke-width="1.5" marker-end="url(#vg-arrow)"/>
  <text x="540" y="110" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">the user downloads a view they may not see</text>
  <text x="360" y="164" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="11">Same outcome for the user, very different cost on a metered connection</text>
  <defs><marker id="vg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor"/></marker></defs>
</svg>

export const router = createRouter({ history: createWebHistory(), routes });

The guard placement is the detail most often missed. An in-component guard — onBeforeRouteEnter inside the view — cannot run until the component exists, so the router must download and evaluate the whole chunk before discovering the navigation should be rejected. On a route the user is not permitted to see, that is the entire chunk fetched for nothing.

Where defineAsyncComponent does belong

Inside a route, for components that are heavy and conditionally rendered — the Vue equivalent of the pattern in component-level code splitting beyond routes:

// views/Reports.vue script block — Vue 3.4+
import { defineAsyncComponent, ref } from 'vue';
import ChartSkeleton from '../components/ChartSkeleton.vue';
import ChartUnavailable from '../components/ChartUnavailable.vue';

const RevenueChart = defineAsyncComponent({
  loader: () => import('../components/RevenueChart.vue'),
  loadingComponent: ChartSkeleton,   // reserves the chart's box
  errorComponent: ChartUnavailable,  // same box, so a failure shifts nothing
  delay: 200,        // no skeleton flash when the chunk is already cached
  timeout: 15000,
});

const showChart = ref(false);
</script>

Here the extra state machine is the point: the component owns a region of the page, so its loading and error states are part of the template rather than an interruption to a navigation.

When a route chunk ships anyway

Three causes, in order of frequency.

Three Ways a View Comes Back A views barrel, a layout building a menu from its children, and a test helper compiled into the app build each create a static path to the view. views barrel re-exports every view layout menu imports children to list them test helper reachable from app code The chunk still exists in the output — it just contains nothing new

A leftover static import. Anything importing the view directly — a test helper included in the app build, a route-name constants file that also imports components, a barrel under views/ — reattaches it to the parent chunk. The mechanism is identical to the one described in refactoring barrel files to reduce bundle bloat.

A shared layout pulling views in. A layout component that imports its child views to build a menu creates a static edge from the layout to every view.

Chunk merging by size. A view smaller than the bundler’s minimum chunk size can be merged back into its parent. The chunk is genuinely absent from the output rather than misconfigured — check the minimum size threshold before hunting for an import.

One Static Edge Undoes the Split The router reaches the view through a dynamic import, but a views barrel file also imports it statically, so the view is bundled into the parent chunk and the lazy chunk is empty of new code. router.js views/index.js barrel re-export Dashboard.vue dynamic static main chunk contains Dashboard.vue dashboard chunk exists but adds nothing The static edge wins: a module reachable statically is never deferred

Step-by-step verification

  1. Count the chunks. After building, confirm there is one chunk per route, named after the view.

  2. Navigate with the network panel open. Each navigation should fetch exactly one new chunk, and revisiting a route should fetch none.

  3. Confirm the main chunk shrank. If the route chunks exist but the main chunk is unchanged, a static import is still reaching the views.

  4. Test a rejected navigation. Attempt a guarded route without permission and confirm no chunk request appears.

  5. Check the transition. A route change should show one visual transition, not a flash of a loading component followed by the view.

  6. Confirm error handling. Block a route chunk and confirm the router’s navigation-failure path runs, rather than the application hanging on an unresolved navigation — the classification approach in handling lazy chunk load failures and fallbacks applies unchanged.

Edge cases and gotchas

Nested routes. A child route’s chunk is fetched only when that child is matched, but the parent’s chunk is fetched for every child. Keep shared layout code in the parent deliberately, not accidentally.

Route-level data fetching. If a guard triggers a data request while the chunk is downloading, the two run in parallel — which is good — but an error in either must be handled distinctly, or a failed fetch looks like a failed chunk.

Keep-alive and cached views. A cached view holds its chunk in memory, so a later navigation shows no request. That is correct behaviour and not evidence that splitting failed.

Server-side rendering. Under SSR the route component is resolved on the server, so the chunk boundary affects the client manifest rather than the server render. Preloading the matched route’s chunk in the server-rendered HTML removes a round-trip on first navigation.

FAQ

Should route components use defineAsyncComponent or a plain dynamic import?

A plain dynamic import in the route record. Vue Router already understands a component field that is a function returning a promise, and it resolves that promise as part of the navigation, so the router’s own transition covers the loading period. Wrapping the same import in defineAsyncComponent adds a second layer of loading state that the router does not coordinate with, which commonly produces a flash of the async loading component during an otherwise smooth navigation.

Why is my route chunk fetched even when the guard rejects the navigation?

Because the guard is defined inside the route component rather than in the route record. An in-component guard cannot run until the component exists, so the router must fetch and evaluate the chunk first, and only then discover that the navigation should not proceed. Moving the check to a route-record guard or a global guard lets the router reject before any request is made.

Do route chunks need Suspense in Vue 3?

Not for the route component itself — the router awaits the chunk before rendering the view, so there is no pending state to render. Suspense becomes relevant when the route component uses async setup or renders async children of its own, in which case a boundary inside the route gives those a place to show loading state without holding up the navigation.