
Let’s talk about a very specific kind of guilt: the guilt of shipping a JavaScript bundle so big it has its own gravitational pull.
You know the feeling. You open your dev tools, check the Network tab, and there it is β a single bundle.js file the size of a small documentary, loading in full before your user can even see a “Sign In” button. Somewhere in there is your entire admin dashboard, your rarely-used settings page, and that one chart library you imported for a feature three people use.
The good news: Vue 3 has a built-in fix for this, and it’s been sitting right there in the docs the whole time, quietly judging your bundle size. It’s called defineAsyncComponent, and once you understand it, you’ll wonder why you were ever making users download the whole toy box just to play with one toy.
By the end of this article, you’ll know exactly how it works, when to use it, when not to use it, and you’ll have a working demo you can poke at yourself. I’ve also put a full working example on GitHub, linked at the bottom, so you don’t have to just take my word for any of this.
The Problem: Loading Everything, Using Almost Nothing
Here’s the thing about large Vue applications β they’re large. Shocking, I know. But the issue isn’t really the size, it’s when everything gets loaded.
By default, if you import a component like this:
import AdminPanel from './components/AdminPanel.vue'
That component gets bundled into your main JavaScript file and loaded every single time, whether the user is an admin, a regular user, or a bot that wandered in from a search engine and has zero interest in your admin panel.
Multiply that by every modal, every rarely-visited page, and every “just in case” component, and you’ve got a bundle that takes longer to load than it takes to make a decent cup of coffee.
The Fix: defineAsyncComponent
Vue’s answer to this is refreshingly simple. Instead of importing a component up front, you tell Vue: “only go get this thing when it’s actually needed.”
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => {
return new Promise((resolve, reject) => {
// go fetch the component from wherever
resolve(/* the loaded component */)
})
})
defineAsyncComponent takes a loader function that returns a Promise. When the Promise resolves, Vue swaps in the real component. When it rejects, Vue can show you a fallback instead of just quietly dying inside.
In practice, you’ll almost always pair this with a dynamic import(), especially if you’re using Vite or webpack (which, statistically, you probably are):
const AsyncComp = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
)
That import() syntax isn’t just convenient β bundlers actually recognize it as a code-splitting point. Translation: your bundler will automatically chop that component out into its own separate file, and only load it when it’s needed. You don’t have to configure anything extra. It just happens. It’s the closest thing to free performance you’ll get in this industry.
Using It Like a Normal Component
Here’s the part that makes this genuinely pleasant to use: once you’ve wrapped a component in defineAsyncComponent, you use it exactly like a regular component. Same props, same slots, same everything.
<script setup>
import { defineAsyncComponent } from 'vue'
const AdminPage = defineAsyncComponent(() =>
import('./components/AdminPageComponent.vue')
)
</script>
<template>
<AdminPage />
</template>
Vue quietly takes care of the “wait for it to load” logic behind the scenes. Your template doesn’t need to know or care that this component technically doesn’t exist yet.
You can also register async components globally, if you’re the type of developer who likes making sweeping architectural decisions from a single file:
app.component('MyComponent', defineAsyncComponent(() =>
import('./components/MyComponent.vue')
))
Handling Loading and Error States (Because Networks Are Liars)
Here’s where a lot of tutorials stop, and it’s a shame, because this is the part that actually matters in production. Networks fail. Servers hiccup. Someone’s on a train going through a tunnel. You need to handle that.
defineAsyncComponent accepts an options object for exactly this:
const AsyncComp = defineAsyncComponent({
loader: () => import('./components/MyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorMessage,
delay: 200,
timeout: 3000
})
loadingComponentβ shown while the real component is still loading. Without this, your user just stares at a blank space, wondering if your app is broken or just deeply contemplative.errorComponentβ shown if the load fails. Without this, the error just kind of… happens, somewhere, and your user is left wondering what they did wrong (nothing, it was probably your Wi-Fi).delayβ how long to wait before showing the loading component. Useful for avoiding a flash of “Loading…” for components that load in 40 milliseconds anyway.timeoutβ how long to wait before giving up and showing the error component instead.
Skip these options and you’re basically deploying your app with no seatbelt. It’ll probably be fine. Probably.
A Quick Working Example

To make this less abstract, I built a small self-contained demo that simulates loading a component from a server, complete with a fake 1.5-second delay so you can actually see the loading state instead of it flashing by in 4 milliseconds like some kind of magic trick.
Click a button, and one of two things happens:
- Success β a loading message appears, then gets replaced by the real component.
- Failure β a loading message appears, then gets replaced by an error message instead.
Here’s the core of it:
const AsyncComp = defineAsyncComponent({
loader: () => fakeServerLoad(), // pretend network request
loadingComponent: LoadingComp,
errorComponent: ErrorComp,
timeout: 5000
})
I put the full working code β HTML, JS, and all β up on GitHub so you can clone it, run it, and break it in your own time:
π github.com/markifornia/vue-projects β define-async-component
No build step required. Open the HTML file in a browser and you’re off.
Caveats (a.k.a. Things That Will Bite You If You Skip This Section)
Async components are great, but they’re not a magic “make my app fast” button. A few things to know before you go wild wrapping every component on your site:
1. Dynamic .vue imports need a bundler. import('./MyComponent.vue') won’t work in a plain browser <script> tag β it needs Vite, webpack, or similar to actually understand .vue files and split them into loadable chunks.
2. Lazy doesn’t mean smart. If a component renders the moment the page loads anyway, wrapping it in defineAsyncComponent doesn’t save you anything β you’ve just added an unnecessary loading flicker and a network round trip for nothing. Save this for stuff that’s conditionally shown: modals, admin panels, tabs, rarely visited routes.
3. <Suspense> changes the rules. If you wrap an async component in <Suspense>, its #fallback slot takes over from loadingComponent/errorComponent/delay/timeout. Mixing the two and expecting both to apply is a classic “why isn’t this working” moment.
4. SSR needs extra thought. Server-side rendering and lazy loading don’t naturally play nice together β naive setups can cause hydration mismatches. Frameworks like Nuxt handle this for you; rolling your own SSR means you need to handle it yourself.
5. Named exports need unwrapping.
defineAsyncComponent(() =>
import('./MyLib.js').then(m => m.MyNamedComponent)
)
Forget the .then() and Vue will try to render the whole module object as a component, which it will not enjoy.
6. Don’t overdo it. Wrapping every tiny component in defineAsyncComponent can backfire, turning one reasonably-sized bundle into a flurry of network requests every time someone clicks something. Use it for genuinely large or rarely-used chunks β not your <Button> component.
7. Retrying isn’t automatic. When a load fails, the errorComponent gets a retry function as a prop β but you have to actually wire up a button to call it. Vue won’t magically retry on its own.
Wrapping Up
defineAsyncComponent is one of those Vue features that feels almost too simple for how much it helps. A few extra lines of code, and suddenly your app isn’t force-feeding every visitor a component they’ll never look at.
The rule of thumb: if it’s big, and it’s not needed immediately, lazy-load it. If it’s tiny, or it’s needed the second the page loads, leave it alone. Your bundle size β and your users’ patience β will thank you.
If you want to see it all in action, grab the demo from GitHub and start breaking things:
π github.com/markifornia/vue-projects β define-async-component
Happy lazy-loading. π



