Async Components, Suspense, Teleport and KeepAlive: Implementation Internals
Chapter 17: Async Components, Suspense, Teleport, and KeepAlive โ Implementation Internals
<KeepAlive>does not "pause" a component โ it physically moves the component's subTree DOM nodes into a separate offline container (document.createElement('div')). The component instance stays alive the entire time; it simply disappears from the viewport. This is why a cached component can "restore" in zero milliseconds without re-running setup() or remounting the DOM.
Core question of this chapter: Vue's four built-in "special components" (async components, Suspense, Teleport, KeepAlive) each have their own dedicated rendering logic. How do they bypass the standard component mounting flow?
After reading this chapter you will understand:
- The complete state machine inside
defineAsyncComponent(loading/error/timeout/retry) - How Suspense captures Promises thrown by descendant components and controls fallback display
- How Teleport maintains two separate tree structures simultaneously โ at the VNode level and at the DOM level
- The LRU cache strategy of KeepAlive and how the "move" operation works
- Traps when combining these four special components
Level 1 ยท What You Need to Know (1โ3 Years Experience)
The Nature of the Four Special Components
| Component | Nature | Problem it solves |
|---|---|---|
| Async Component | A state machine wrapper | Lazy-load component definitions, control loading/error UI |
| Suspense | An async boundary controller | Coordinate multiple async dependencies, unify fallback strategy |
| Teleport | A DOM position router | Render children anywhere in the DOM tree |
| KeepAlive | A component cache manager | Preserve component state, avoid repeated mount/unmount |
Basic Usage of defineAsyncComponent
// Simplest form
const AsyncComp = defineAsyncComponent(() => import('./MyComp.vue'))
// Full configuration
const AsyncComp = defineAsyncComponent({
loader: () => import('./MyComp.vue'),
loadingComponent: LoadingSpinner, // shown while loading
errorComponent: ErrorDisplay, // shown on load failure
delay: 200, // wait 200ms before showing loading (avoids flash)
timeout: 3000, // 3-second timeout
onError(error, retry, fail, attempts) {
if (attempts < 3) retry() // auto-retry up to 3 times
else fail()
}
})
Suspense Usage Scenarios
<template>
<Suspense>
<!-- Default slot: actual content (may contain async components or async setup) -->
<template #default>
<UserProfile /> <!-- async setup: await fetch('/api/user') -->
<PostList /> <!-- async setup: await fetch('/api/posts') -->
</template>
<!-- Fallback slot: shown until all async dependencies resolve -->
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
Note: Suspense waits for ALL descendant async dependencies to resolve before switching to default content. It is not "show as soon as the first one resolves."
Teleport: Rendering Content Elsewhere
<!-- Problem: Modal is nested inside a container with overflow:hidden or z-index issues -->
<div class="card" style="overflow: hidden">
<button @click="showModal = true">Open Modal</button>
<!-- Solution: Teleport inserts Modal's DOM into body, bypassing overflow -->
<Teleport to="body">
<div v-if="showModal" class="modal">
<p>This modal is under body, unaffected by overflow constraints</p>
</div>
</Teleport>
</div>
KeepAlive: Caching Component State
<template>
<KeepAlive
:include="['UserList', 'ProductList']"
:exclude="['LoginForm']"
:max="10"
>
<component :is="currentPage" />
</KeepAlive>
</template>
<script setup>
// Cached components can listen to these two lifecycle hooks
onActivated(() => {
console.log('Component activated (restored from cache)')
refreshData()
})
onDeactivated(() => {
console.log('Component deactivated (entered cache)')
clearTimers()
})
</script>
Level 2 ยท How It Actually Works (3โ5 Years Experience)
The Internal State Machine of defineAsyncComponent
Async component state machine:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Async Component Lifecycle โ
โ โ
โ PENDING โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโบ RESOLVED โ
โ โ โ โ
โ โ loader() fails โ render โ
โ โผ โผ actual โ
โ ERROR โโโ timeout expires After delay, component โ
โ โ show loading โ
โ โ onError() calls retry() โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโบ re-enter PENDING โ
โ โ
โ delay=200ms: wait 200ms in PENDING before showing โ
โ loadingComponent โ
โ timeout=3s: after 3s, if still PENDING โ ERROR โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// packages/runtime-core/src/apiAsyncComponent.ts (simplified)
export function defineAsyncComponent(source) {
if (isFunction(source)) {
source = { loader: source }
}
const { loader, loadingComponent, errorComponent, delay = 200, timeout, onError: userOnError } = source
let resolvedComp: ConcreteComponent | undefined
let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}
const load = () => {
return (
pendingRequest ||
(pendingRequest = loader()
.catch(err => {
err = err instanceof Error ? err : new Error(String(err))
if (userOnError) {
return new Promise((resolve, reject) => {
const userRetry = () => resolve(retry())
const userFail = () => reject(err)
userOnError(err, userRetry, userFail, retries + 1)
})
} else {
throw err
}
})
.then((comp: any) => {
if (comp && (comp.__esModule || comp[Symbol.toStringTag] === 'Module')) {
comp = comp.default
}
resolvedComp = comp
return comp
}))
)
}
// Returns a "wrapper component" (a stateful component internally)
return defineComponent({
name: 'AsyncComponentWrapper',
__asyncLoader: load,
setup() {
const instance = getCurrentInstance()!
if (resolvedComp) {
return () => createInnerComp(resolvedComp!, instance)
}
const onError = (err: Error) => {
pendingRequest = null
handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
}
// Suspense support: if inside Suspense, throw a Promise
if (suspensible && instance.suspense) {
return load()
.then(() => () => createInnerComp(resolvedComp!, instance))
.catch(err => {
onError(err)
return () => errorComponent ? createVNode(errorComponent, { error: err }) : null
})
}
// Non-Suspense mode: use reactive state to control display
const loaded = ref(false)
const error = ref<Error | undefined>()
const delayed = ref(!!delay)
if (delay) {
setTimeout(() => { delayed.value = false }, delay)
}
if (timeout != null) {
setTimeout(() => {
if (!loaded.value && !error.value) {
const err = new Error(`Async component timed out after ${timeout}ms.`)
onError(err)
error.value = err
}
}, timeout)
}
load()
.then(() => { loaded.value = true })
.catch(err => { onError(err); error.value = err })
return () => {
if (loaded.value && resolvedComp) {
return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) {
return createVNode(errorComponent, { error: error.value })
} else if (!delayed.value && loadingComponent) {
return createVNode(loadingComponent)
}
}
},
})
}
How Suspense Works Internally
Suspense's dual-branch structure:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Suspense maintains two branches internally: โ
โ โ
โ pendingBranch: content being loaded (default slot VNode) โ
โ activeBranch: currently displayed (initially fallback) โ
โ โ
โ Mounting process: โ
โ 1. Set pendingBranch = default slot VNode โ
โ 2. Mount pendingBranch (triggers all descendant async setups)โ
โ 3. Each time an async dependency resolves: pendingDeps-- โ
โ 4. When pendingDeps === 0: โ
โ โโโ Move pendingBranch into the real DOM (replace fallback)โ
โ โโโ Set activeBranch = pendingBranch โ
โ โโโ Clear pendingBranch โ
โ โ
โ The "mount but don't insert" trick: โ
โ pendingBranch is mounted into an offline container โ
โ Only after all async deps complete is it moved into real DOM โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// packages/runtime-core/src/components/Suspense.ts (key parts simplified)
function mountSuspense(vnode, container, anchor, ...) {
const suspense = (vnode.suspense = createSuspenseBoundary(vnode, ...))
// Mount fallback (shows immediately)
suspense.activeBranch = normalizeSuspenseSlot(vnode.ssContent)
patch(null, suspense.activeBranch, container, anchor, ...)
// Mount pendingBranch in an offline container
const pendingBranch = (suspense.pendingBranch = normalizeSuspenseSlot(vnode.ssFallback))
patch(null, pendingBranch, suspense.hiddenContainer, null, ...)
// Note: hiddenContainer = document.createElement('div')
}
// Called when an async dependency resolves
function resolveSuspense(suspense) {
suspense.pendingDeps--
if (suspense.pendingDeps === 0) {
const { activeBranch, pendingBranch, container } = suspense
// Unmount the old activeBranch (fallback)
unmount(activeBranch, ...)
// Move pendingBranch from offline container into real DOM
move(pendingBranch, container, ...)
suspense.activeBranch = pendingBranch
suspense.pendingBranch = null
queuePostFlushCb(suspense.effects)
}
}
Teleport's Two-Layer Tree Structure
Teleport: VNode tree vs DOM tree
VNode tree (logical structure, mirrors template):
App
โโโ Card (has overflow:hidden)
โโโ Teleport (to="body")
โโโ Modal
Real DOM tree (physical structure):
<body>
<div id="app">
<div class="card"> โ Card's DOM
<!-- Teleport's position in the VNode tree -->
<!-- but its child Modal is NOT here in the DOM -->
</div>
</div>
<div class="modal"> โ Modal's DOM (result of Teleport to="body")
</div>
</body>
Renderer maintains two sets of references:
Teleport VNode.el โ anchor text node inside Card
Teleport VNode.target โ the body element (the target pointed to by `to`)
// packages/runtime-core/src/components/Teleport.ts (simplified)
const TeleportImpl = {
__isTeleport: true,
process(n1, n2, container, anchor, parentComponent, ...) {
const target = (n2.target = resolveTarget(n2.props, querySelector))
const targetAnchor = (n2.targetAnchor = hostCreateText(''))
hostInsert(targetAnchor, target!) // insert anchor into target container
if (n1 == null) {
// Mount: insert children into target (not container!)
mountChildren(n2.children, target!, targetAnchor, ...)
} else {
const { children, props } = n2
if (n2.target !== n1.target) {
// Target changed: move all children to new target
const nextTarget = (n2.target = resolveTarget(n2.props, querySelector))
children.forEach(c => move(c, nextTarget!, ...))
} else {
patchChildren(n1, n2, target!, targetAnchor, ...)
}
}
},
remove(vnode, ...) {
const { children, target, targetAnchor } = vnode
removeFragmentNodes(children, target!, ...)
hostRemove(targetAnchor!)
},
}
KeepAlive's LRU Cache Implementation
KeepAlive LRU cache demonstration (max = 3):
Initial: cache = {}, keys = []
Access A: cache = {A: instanceA}, keys = [A]
Access B: cache = {A, B}, keys = [A, B]
Access C: cache = {A, B, C}, keys = [A, B, C] โ cache full
Access A (already cached):
Remove A from keys, re-append at end
keys = [B, C, A] (A is the most recently used)
Access D (new component, cache full):
Need to evict LRU (least recently used) = keys[0] = B
Unmount B's component instance, remove B from cache
cache = {C, A, D}, keys = [C, A, D]
// packages/runtime-core/src/components/KeepAlive.ts (simplified)
const KeepAliveImpl = defineComponent({
name: 'KeepAlive',
__isKeepAlive: true,
setup(props, { slots }) {
const instance = getCurrentInstance()!
const ctx = instance.ctx as KeepAliveContext
const storageContainer = createElement('div')
ctx.activate = (vnode, container, anchor, ...) => {
// Move from storageContainer into container (O(1) DOM operation!)
move(vnode, container, anchor, MoveType.ENTER, ...)
queuePostFlushCb(invokeOnActivated)
}
ctx.deactivate = (vnode) => {
// Move from current container into storageContainer (O(1) DOM operation!)
move(vnode, storageContainer, null, MoveType.LEAVE, ...)
queuePostFlushCb(invokeOnDeactivated)
}
const cache: Cache = new Map()
const keys: Keys = new Set()
function pruneCacheEntry(key: CacheKey) {
const cached = cache.get(key) as VNode
if (!current || !isSameVNodeType(cached, current)) {
unmount(cached) // Actually unmounts: calls onBeforeUnmount/onUnmounted
}
cache.delete(key)
keys.delete(key)
}
return () => {
const children = slots.default?.()
if (!children?.length) return null
const rawVNode = children[0]
if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT)) {
return rawVNode
}
let vnode = rawVNode
const comp = vnode.type as ConcreteComponent
const name = getComponentName(comp)
if ((include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))) {
return vnode
}
const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key)
if (cachedVNode) {
// Cache hit! Reuse component instance
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
// Mark COMPONENT_KEPT_ALIVE: tells renderer not to remount
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
// Update LRU order
keys.delete(key)
keys.add(key)
} else {
// Cache miss: first mount
keys.add(key)
// LRU: if over max, evict oldest
if (max && keys.size > parseInt(max as string, 10)) {
pruneCacheEntry(keys.values().next().value)
}
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
}
cache.set(key, vnode)
return vnode
}
}
})
Level 3 ยท Design Docs and Source Code (Senior Developers)
How Suspense Captures Descendants' Promises
When an async component returns a Promise from setup() (rather than an object or render function), the renderer recognizes this:
// packages/runtime-core/src/renderer.ts
function setupStatefulComponent(instance) {
const { setup } = instance.type
const setupResult = callWithErrorHandling(setup, instance, ...)
if (isPromise(setupResult)) {
setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
if (instance.suspense) {
// Has a Suspense ancestor: bubble the Promise up
instance.suspense.registerDep(instance, setupResult)
} else {
instance.asyncDep = setupResult
}
return
}
// ... normal processing
}
The core logic of Suspense.registerDep:
function createSuspenseBoundary(vnode, ...) {
return {
registerDep(instance, setupPromise) {
suspense.pendingDeps++ // increment counter
setupPromise.then(asyncSetupResult => {
instance.asyncResolved = true
finishComponent(instance, asyncSetupResult)
suspense.pendingDeps-- // decrement counter
if (suspense.pendingDeps === 0) {
resolveSuspense(suspense)
}
}).catch(err => {
handleError(err, instance, ErrorCodes.SETUP_FUNCTION)
suspense.pendingDeps--
})
}
}
}
Teleport's disabled Prop Implementation
const TeleportImpl = {
process(n1, n2, container, anchor, ...) {
const disabled = isTeleportDisabled(n2.props)
if (disabled) {
// Disabled mode: mount directly into container (standard behavior)
mountChildren(n2.children, container, anchor, ...)
} else {
const target = resolveTarget(n2.props, querySelector)
mountChildren(n2.children, target, targetAnchor, ...)
}
}
}
// Common use case: teleport based on screen size
const isMobile = ref(window.innerWidth < 768)
// <Teleport to="body" :disabled="!isMobile">
// Desktop: render in place; Mobile: teleport to body
KeepAlive's COMPONENT_KEPT_ALIVE Flag and Renderer Cooperation
The renderer checks the COMPONENT_KEPT_ALIVE flag in mountComponent:
// packages/runtime-core/src/renderer.ts
const processComponent = (n1, n2, container, anchor, ...) => {
if (n1 == null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
// Restoring from cache: call KeepAlive's activate method
// This moves the DOM from storageContainer into container
parentComponent.ctx.activate(n2, container, anchor, ...)
} else {
mountComponent(n2, container, anchor, ...)
}
} else {
updateComponent(n1, n2, ...)
}
}
// When a KeepAlive child is switched out (not unmount, but deactivate)
const unmountComponent = (instance, ...) => {
if (instance.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
// Don't actually unmount โ move to offline container
instance.ctx.deactivate(instance.vnode)
return
}
// Otherwise, normal unmount...
}
Suspense's Error Capture Mechanism
Suspense and onErrorCaptured are two separate mechanisms:
// onErrorCaptured handles synchronous errors and some async errors within components
// Suspense specifically handles Promise rejections from setup()
// If a component inside Suspense rejects in its setup() Promise:
setupPromise.catch(err => {
handleError(err, instance, ErrorCodes.SETUP_FUNCTION)
// If errorComponent is configured, show it
// Otherwise, bubble up to onErrorCaptured
})
// Recommended combination pattern:
// 1. Suspense fallback handles loading state
// 2. Outer onErrorCaptured handles error state
const MyPage = defineComponent({
errorCaptured(err) {
showErrorToast(err.message)
return false // prevent further propagation
},
template: `
<Suspense>
<template #default><AsyncContent /></template>
<template #fallback><Skeleton /></template>
</Suspense>
`
})
Level 4 ยท Edge Cases and Traps (Everyone)
Trap 1: KeepAlive Caches VNodes, Not DOM Nodes Directly
// Common misconception: KeepAlive stores DOM nodes somewhere
// Reality: KeepAlive caches VNodes that hold references to component instances
// The actual DOM nodes live inside storageContainer (an offline div)
// This means:
// 1. Cached components still consume memory (component instance + DOM nodes)
// 2. The max prop limits the number of cached components
// 3. When max is exceeded, the LRU component is truly unmounted (onBeforeUnmount is called)
Trap 2: Teleport's Target Element Must Exist When mount Is Called
// Wrong: the target element might not be rendered yet
const app = createApp(App)
app.mount('#app')
// At this point #modal-root might not exist!
// Correct approach 1: ensure the target element exists in the HTML before mount
// <div id="modal-root"></div> โ written in the HTML file
// Correct approach 2: dynamic target
const teleportTarget = ref('#app') // initially teleport to #app
onMounted(() => {
const el = document.createElement('div')
el.id = 'modal-root'
document.body.appendChild(el)
teleportTarget.value = '#modal-root'
})
Trap 3: v-if Inside KeepAlive Causes True Unmounting
<template>
<KeepAlive>
<!-- Wrong: when v-if is false, the component is UNMOUNTED โ KeepAlive has no effect! -->
<MyComp v-if="show" />
<!-- Correct: use v-show to control visibility, component stays inside KeepAlive -->
<MyComp v-show="show" />
</KeepAlive>
</template>
Trap 4: Error Capture Behavior in Nested Suspense
<!-- Nested Suspense error handling is not intuitive -->
<Suspense>
<template #default>
<Suspense> <!-- inner Suspense -->
<template #default>
<AsyncComp /> <!-- if this throws an error -->
</template>
<template #fallback>inner loading</template>
</Suspense>
</template>
<template #fallback>outer loading</template>
</Suspense>
<!--
When AsyncComp's setup Promise rejects:
The inner Suspense captures the error first.
If the inner Suspense has no errorComponent,
the error propagates up to the outer Suspense or onErrorCaptured.
This behavior was unstable before Vue 3.3; ensure version >= 3.3
-->
Trap 5: Event Listener Leaks When Combining Teleport + KeepAlive
<template>
<KeepAlive>
<component :is="currentPage" />
</KeepAlive>
</template>
<!-- Problem: if currentPage contains a Teleport,
the Teleport's target DOM content is not cleaned up on deactivate.
Teleport children remain in body even while the component is "in cache." -->
<script setup>
onDeactivated(() => {
// Manually clean up side effects produced by Teleport
document.removeEventListener('click', globalClickHandler)
})
</script>
Trap 6: Confusing the Units of defineAsyncComponent's delay and timeout
const AsyncComp = defineAsyncComponent({
loader: () => import('./Heavy.vue'),
delay: 200, // unit: milliseconds. Wait 200ms before showing loadingComponent
timeout: 3000, // unit: milliseconds. Show errorComponent if not loaded after 3000ms
// Common mistake: assuming delay is in seconds
// delay: 2 is NOT 2 seconds โ it is 2 milliseconds (loading shows almost instantly)
// Common mistake: timeout set too short
// timeout: 300 will almost certainly time out on a 3G network
})
Chapter Summary
-
Async components are state machine wrappers:
defineAsyncComponentreturns not the original component, but a stateful wrapper component that internally manages three states (PENDING/RESOLVED/ERROR).delayandtimeoutare two independent timers that independently control when loading UI appears and when timeout is detected. -
Suspense coordinates multiple async dependencies using a pendingDeps counter: When a descendant's async setup returns a Promise, Suspense intercepts that Promise and increments the counter by one. Only after all Promises resolve does Suspense move the offline container's content into the real DOM, replacing the fallback.
-
Teleport maintains two independent trees: In the VNode tree, Teleport's children are still logical children of the Teleport VNode (for diffing purposes). In the DOM tree, children are physically inserted under the element pointed to by the
toattribute, completely separate from the VNode tree's logical structure. -
KeepAlive's "caching" is a DOM relocation, not a copy: A deactivated component has its DOM subtree physically moved into an offline div (
storageContainer); on activation it is moved back. The entire process is O(1) DOM operations with no re-execution of setup(). LRU eviction triggered by themaxprop truly callsonBeforeUnmount/onUnmounted. -
All four special components bypass the standard mount flow: Each implements its own
process/remove/move/hydratemethods. The renderer identifies them viashapeFlag(TELEPORT/SUSPENSE) or special marks (COMPONENT_KEPT_ALIVE) and delegates to these methods rather than going through the genericmountElement/mountComponentpaths.