Chapter 17

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:

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

  1. Async components are state machine wrappers: defineAsyncComponent returns not the original component, but a stateful wrapper component that internally manages three states (PENDING/RESOLVED/ERROR). delay and timeout are two independent timers that independently control when loading UI appears and when timeout is detected.

  2. 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.

  3. 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 to attribute, completely separate from the VNode tree's logical structure.

  4. 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 the max prop truly calls onBeforeUnmount/onUnmounted.

  5. All four special components bypass the standard mount flow: Each implements its own process/remove/move/hydrate methods. The renderer identifies them via shapeFlag (TELEPORT/SUSPENSE) or special marks (COMPONENT_KEPT_ALIVE) and delegates to these methods rather than going through the generic mountElement/mountComponent paths.

Rate this chapter
4.9  / 5  (14 ratings)

💬 Comments