Skip to content
intermediate Phase 14 · Performance Optimization and Server-Side Rendering

Async Components and Code Splitting

Defer heavy components and split vendor code into cacheable chunks.

40m
0 problems
Topic Progress 0%

Your Bundle Ships a Chart Editor to Every Visitor Who Never Opened One

A visitor signs into your dashboard and the browser pulls down 2.1 MB of JavaScript — including the Monaco-backed chart editor they will never open, a PDF viewer, and a date-range picker imported by exactly one settings form. Profiling Render Performance taught you how to find that weight in a flame graph; the fix for module-level waste is not micro-tuning your components, it is not shipping code the current route can possibly use.

Vue's tool for that is defineAsyncComponent. You hand it a factory that returns a dynamic import(), and Vue swaps the real component in only when it reaches the render path:

<script setup>
import { defineAsyncComponent } from 'vue'

const ChartEditor = defineAsyncComponent(() =>
  import('../components/ChartEditor.vue')
)
</script>

The first time <ChartEditor /> is about to render, Vue calls your factory. Vite emits that component — plus its echarts and monaco-editor dependencies — as separate chunk files with content hashes (assets/ChartEditor-7f3a91c2.js, assets/echarts-2b84d5e1.js), and the browser fetches them at that moment, not at boot. Until then the entry bundle never references them, because Rollup treats a dynamic import() as a hard boundary: any module reachable only through it stays out of the importing chunk.

The same mechanism works one level up, on routes:

// router/index.js
{
  path: '/reports/:id',
  component: () => import('../views/ReportView.vue')
}

vue-router holds no static reference to the view module, so / boots with only the shell. A realistic split looks like this:

Chunk Contents gzip
entry Vue runtime, router, pinia, app shell ~55 KB
home landing route components ~18 KB
reports report view + chart libraries ~340 KB
editor Monaco + editor UI ~1.4 MB

That table is the argument: a visitor who never opens a report pays 73 KB instead of 1.8 MB, and first input happens hundreds of milliseconds sooner on a throttled phone.

Why not a plain top-level import { ChartEditor } from '../components/ChartEditor.vue'? Because a static import is a compile-time guarantee of inclusion — tree-shaking only removes it if no path uses it, and your template does. The dynamic import is a runtime promise: fetch on demand. What you trade away is a network round trip in the middle of a user interaction, which is exactly the gap the async component's loading and error states exist to cover (chapter 3).

The user-visible payoff is measurable, and interviewers like asking for the measurement. Your entry chunk determines how quickly the router can mount anything, so shrinking it moves FCP and LCP directly; on a Moto G-class phone over 4G, cutting 1.7 MB of JavaScript typically saves well over a second of parse and execute time, because those devices spend their budget interpreting bytes the desktop never notices. Fewer bytes also mean fewer long tasks after hydration, which is what INP is scored on.

Two mistakes show up in code review constantly. First, passing the promise instead of the factory: defineAsyncComponent(import('../Editor.vue')) fires the request the moment the module is evaluated, so the chunk loads eagerly and you have paid for async with none of its benefits. The argument must be a function that performs the import. Second, when the target file exports a named component rather than a default, the factory must unwrap the module: () => import('../Editor.vue').then(m => m.ChartPanel).

There is also a legitimate middle ground between blocking and deferring: prefetch after the critical path is done. Calling the same factory inside requestIdleCallback, or emitting <link rel="prefetch"> for the chunk, warms the file in the HTTP cache without competing with first paint. What you should never do is wrap a component that renders above the fold — you would add an async mount delay to every page view in exchange for nothing.

Splitting Vendor Code So a One-Line Change Stops Invalidating 250 KB

Every deploy that changes your app code changes the hash of the entry chunk, so every user re-downloads it. That is fine when the entry is 55 KB. It stops being fine when Vue, the router, the store, echarts, and date-fns are all welded into the same file as your components: now a one-line template change invalidates 250 KB of third-party code nobody has touched. The fix is chunk partitioning, and in Vite it lives in one place:

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/[name]-[hash].js',
        manualChunks(id) {
          if (!id.includes('node_modules')) return
          if (/vue|pinia|router/.test(id)) return 'vendor'
          // everything else from node_modules stays attached to whatever
          // chunk imports it (usually a lazy route chunk)
        }
      }
    }
  }
})

The rule that keeps this honest: only hoist vendors into a shared chunk that the entry genuinely imports. vue, vue-router, and pinia are on every critical path, so grouping them into vendor is pure win — your app chunk changes every deploy, vendor-8c1f2a44.js keeps its hash, and returning visitors skip it entirely under an immutable cache header. But drag echarts into that same chunk while only a lazy route uses it and you have re-created the original problem: Rollup's chunk graph makes vendor a dependency of the entry, and now every visitor downloads the charting library at boot.

The math is what you say in an interview. Without partitioning, a 250 KB vendor blob rides along with each release, so a team deploying twice a day costs users 500 KB of redundant transfer per day. With vendor split out, only the app chunk (say 60 KB) rotates; the vendor file rotates when a dependency actually changes — typically once a month. Cache hit rate is a function of chunk stability, not chunk size. Which also constrains naming: never put a build timestamp into chunkFileNames — a filename that changes for identical content destroys the caching the partition was built to protect.

Rollup already does part of this for you: when a module is imported by two or more chunks, it is hoisted into a shared chunk automatically instead of being duplicated, so a composable package used by three lazy routes ships once. manualChunks exists for the cases the heuristic cannot know — which dependencies you consider "stable core" versus "route-specific payload".

Naming matters as much as splitting. The defaults (assets/[name]-[hash].js) already give you content-addressed filenames; overriding entryFileNames/chunkFileNames with a scheme like assets/[name]-[hash].js plus a version folder (assets/1.4.2/[name]-[hash].js) makes it obvious which release a stale log line came from. Keep the hash in the name no matter what — the filename hash is the mechanism your Cache-Control: immutable header leans on.

Watch the emitted output, because it is the ground truth. A build reports each chunk with its compressed size:

dist/index.html                      0.46 kB │ gzip:  0.30 kB
dist/assets/vendor-8c1f2a44.js     142.11 kB │ gzip: 54.62 kB
dist/assets/index-a91d0f33.js       58.94 kB │ gzip: 19.07 kB
dist/assets/ReportView-4d7e6b21.js  41.20 kB │ gzip: 13.85 kB

Set build.reportCompressedSize: false when you want raw byte truth instead of gzip estimates (useful when a CI job compares builds produced with different gzip levels), and reach for npx vite-bundle-visualizer when one chunk balloons and you need to know which module grew. A practical guardrail: fail CI when the entry chunk passes your budget — a size-limit config takes five lines — because bundle creep is invisible in review. It arrives one merged PR at a time, and only a size check makes each of those PRs state its cost.

The Gap After the Split: Spinners, Timeouts, and Chunks That Vanish on Deploy

Splitting the bundle creates a new user-visible event: the moment a chunk is not there yet. A component appears one frame later than the click that needed it, the network stalls on a hotel wifi, or — the nastiest case — the fetch fails outright because the file no longer exists. defineAsyncComponent gives you four knobs for all of it:

const ChartEditor = defineAsyncComponent({
  loader: () => import('../components/ChartEditor.vue'),
  loadingComponent: SpinnerBlock,
  delay: 200,          // ms before the spinner shows at all
  timeout: 10000,      // ms before the error state; -1 disables
  errorComponent: LoadFailed
})

The defaults are deliberate. delay is 200 ms precisely so a chunk that arrives in 40 ms never flashes a spinner — the loading component occupies the slot only when the fetch is genuinely slow, which keeps fast interactions looking synchronous. timeout defaults to -1 (wait forever), so anything that can stall a real user — a flaky CDN edge, a captive portal — should get an explicit value. The errorComponent is where a failed import lands; without it, Vue logs an unhandled error and renders nothing where your page used to be.

Design the loading component with the same rigor as the page itself: give it the approximate height of the real content so the swap does not shift the layout (a 0-height spinner collapsing into a 600 px editor is a CLS regression you created with your own optimization), and keep it cheap — a spinner plus skeleton lines, no chart simulation. A loading state that costs 40 KB and a second render pass undermines the point of deferring the chunk it guards.

The failure you will actually meet in production looks like this: a user keeps your tab open overnight, you deploy, the old hashed chunk ChartEditor-7f3a91c2.js is gone from the CDN, and their next click throws Failed to fetch dynamically imported module. Their index.html (cached, or served from a bfcache) points at files that no longer exist. Two defenses, used together:

onError((error, retry) => {
  if (sessionStorage.getItem('chunk-reloaded')) throw error
  sessionStorage.setItem('chunk-reloaded', '1')
  location.reload()            // fresh index.html → correct hashes
}, /* attempts */ 1)

and, on the server side, keeping the previous release's assets/ alive for a grace window (24–48 hours) so in-flight sessions never 404. The sessionStorage guard exists because an unconditional reload on chunk error turns a rare failure into an infinite refresh loop when the file is genuinely gone.

Prefetching is how you avoid the spinner entirely for known next steps. A route-level () => import() tells the router nothing about intent, but you can warm likely targets after first paint:

requestIdleCallback(() => {
  import('../views/ReportView.vue')
  import('../components/ChartEditor.vue')
})

The module lands in the HTTP cache; when the user navigates, the async component resolves from cache and — with the 200 ms delay — renders without ever showing a loader. Budget this carefully: prefetching every lazy chunk on every page means you downloaded the whole app, just in the wrong order.

Two last cautions. Do not use an async component for anything in the initial render tree of your entry route — you pay a network round trip before first contentful paint on every single visit. And if the component is reachable during server rendering, remember that the server cannot await a browser fetch the way the client can; async components plus SSR need a loading story that works without window, which is the next topic: Server-Side Rendering and Hydration.

Quiz

1. A teammate writes `const Editor = defineAsyncComponent(import('../components/Editor.vue'))`. What is the result?

Question 1 options

2. After changing a route to `component: () => import('../views/Admin.vue')`, what does `vite build` emit?

Question 2 options

3. Your manualChunks config places echarts in a `vendor` chunk that the entry bundle also imports. What do users pay?

Question 3 options

4. A user reports `Failed to fetch dynamically imported module` for the first time minutes after your new release went live. What most likely happened?

Question 4 options

Flashcards

Question

What does `defineAsyncComponent(() => import(...))` change about when a component's code is downloaded?

Answer

The factory runs only when the component is about to render, so Rollup emits its module graph as a separate hashed chunk and the browser fetches it at that moment. A static import would place the same code in the entry chunk, downloaded by every visitor at boot.

Question

What are the defaults for an async component's delay and timeout, and why do they matter?

Answer

delay defaults to 200 ms — the loading component only appears if the chunk is slower than that, preventing spinner flashes on fast connections. timeout defaults to -1 (never), so you must set an explicit value (e.g. 10000) if you want a stalled fetch to fall into the error state.

Question

What does manualChunks actually buy you in terms of caching?

Answer

It makes chunk hashes stable per responsibility: your app chunk changes every deploy while a `vendor` chunk containing entry-critical node_modules keeps its hash until dependencies change. Under `Cache-Control: immutable`, returning visitors skip the unchanged vendor file entirely — cache hit rate follows chunk stability, not chunk size.

Question

Why does a dynamic import() create a separate chunk while a static import does not?

Answer

Rollup treats dynamic import() as a hard module-graph boundary: modules reachable only through it are excluded from the importing chunk and emitted as their own content-hashed file. Static imports are always part of the importing chunk (subject only to tree-shaking of genuinely unused exports).

Question

How do you handle the error state of a lazy chunk after a deploy removes its hashed file?

Answer

Use the onError hook (or errorComponent) to detect `Failed to fetch dynamically imported module`, then reload the page once — guarded by a sessionStorage flag so a truly missing file cannot cause an infinite reload loop. On the server, keep the previous release's assets/ for a 24–48 hour grace window.

Revision Notes

Key Takeaways

  • 1. defineAsyncComponent takes a *factory* — passing `import(...)` directly fires the request at module evaluation and defeats the whole mechanism
  • 2. A dynamic import is a hard Rollup boundary: anything reachable only through it is excluded from the importing chunk, which is why route-level `() => import()` shrinks the boot payload
  • 3. manualChunks is safe only for libraries the entry truly imports; hoisting a route-only vendor into it makes every visitor download that vendor at boot
  • 4. Cache efficiency comes from chunk stability — split the frequently changing app code from the rarely changing vendor so hashes (and immutable URLs) survive most deploys
  • 5. Splitting creates a fetch gap: cover it with delay/timeout/errorComponent, and handle stale hashed chunks with a single guarded reload plus a server-side assets grace window

Interview Tips

  • • "Difference between a lazy component and a lazy route?" — same mechanism (dynamic import → chunk boundary), different trigger: the component loads when it reaches the render path, the route loads on navigation; name defineAsyncComponent as the component-side API
  • • "How would you keep a 1.4 MB editor out of your first load?" — wrap it in defineAsyncComponent, lazy-route it, verify the emitted chunk in vite build output, and say explicitly what you do NOT do: wrap above-the-fold components
  • • "What happens to caching when you split vendor chunks?" — hashed vendor file keeps its hash across app-only deploys, immutable header lets the CDN serve it from cache; answer with the mechanism (content hash) not the buzzword
  • • "A user sees Failed to fetch dynamically imported module after deploy — diagnose it" — stale index.html pointing at deleted hashed assets; give both fixes (client: guarded reload; server: keep old assets briefly) to show you have debugged it for real

Cheat Sheet

Async Components and Code Splitting

Lazy component:

const ChartEditor = defineAsyncComponent({
  loader: () => import('../components/ChartEditor.vue'),  // factory, not the promise
  loadingComponent: SpinnerBlock,
  delay: 200,        // spinner only after 200 ms (default)
  timeout: 10000,    // default -1 = wait forever
  errorComponent: LoadFailed
})
// named export: () => import('../X.vue').then(m => m.Editor)

Lazy route:

{ path: '/reports/:id', component: () => import('../views/ReportView.vue') }

Vendor partition (vite.config.js):

build: { rollupOptions: { output: {
  chunkFileNames: 'assets/[name]-[hash].js',
  manualChunks(id) {
    if (id.includes('node_modules') && /vue|pinia|router/.test(id)) return 'vendor'
  }
} } }
Rule Why
Hoist only entry-critical vendors entry → chunk dependency makes everything eager
Leave route-only vendors lazy echarts/Monaco must not ride in the boot payload
Keep hashes in filenames pairs with Cache-Control: immutable
Budget the entry chunk in CI bundle creep is invisible in code review

Post-deploy failure: old index.html + deleted hashed chunk → Failed to fetch dynamically imported module. Reload once (sessionStorage guard) + retain previous assets/ 24–48 h.

Continue Learning