← blog
VueNovember 9, 2026 · 14 min read

Vue Teleport defer: The Target-Timing Bug Behind a Dozen Patches

Vue's Teleport defer prop took a dozen follow-up fixes after 3.5 — the target-timing bug, the Suspense interaction, plus a copy-paste cheat sheet.

Parsa Jiravand · Frontend engineer · building bestpractic
Vue Teleport defer: The Target-Timing Bug Behind a Dozen Patches

You add a <Teleport to="#modal-root"> to a component, the modal works perfectly in your dev sandbox, and then it silently fails to render the first time it's mounted inside a real page layout — no error in your code, just a console warning about a target that "doesn't exist," even though you can see the element sitting right there in the DOM inspector. The target exists. It just didn't exist yet, at the one moment Vue checked for it.

By the end of this article you'll be able to:

  • Explain exactly when Vue resolves a <Teleport> target, and why that timing — not the target's existence — is the actual bug surface
  • Use the defer prop correctly, and name the one case it does not fix
  • Read the real changelog history behind defer: a dozen-plus follow-up patches across Suspense, SSR, and disabled-state handling, the newest landing in the current stable release
  • Reproduce the classic "target renders after the Teleport" failure and fix it live
  • Apply the right defensive pattern so your own Teleport usage doesn't depend on component mount order

You've used <Teleport> to render a modal, a tooltip, or a toast into a fixed DOM location and it has mostly just worked. This article assumes that baseline. If component mount timing in general — when the DOM actually exists relative to a component's lifecycle — is still fuzzy, Vue nextTick: How DOM Updates Are Batched covers the adjacent queue this article's timing model sits next to. Useful background, not required reading.

This is written and tested against Vue 3.5.44 — the release carrying the most recent Teleport-specific fix. (npm's latest tag has since moved to 3.5.45, verified November 2026, which ships no Teleport changes, so nothing below is stale.) Vue 3.6 is still on the rc tag (3.6.0-rc.14 at the time of writing) and hasn't reached stable, so nothing here depends on it. Everything below — the defer prop, the mount-order behavior, and every patch referenced — ships in 3.5.x today.

Here's the setup almost everyone hits once: a layout component renders a modal-trigger area before it renders the container the modal is supposed to teleport into.

Vue
1
2
3
4
5
6
7
<!-- AppShell.vue --> <template> <Header /> <ModalTrigger /> <!-- the teleport target is defined further down the tree --> <div id="modal-root"></div> </template>
Vue
1
2
3
4
5
6
7
8
9
10
11
12
<!-- ModalTrigger.vue --> <template> <button @click="open = true">Open modal</button> <Teleport to="#modal-root"> <div v-if="open" class="modal">Hello from a teleported modal</div> </Teleport> </template> <script setup> import { ref } from 'vue' const open = ref(false) </script>

This looks completely reasonable, and in a plain top-to-bottom static template like the one above it actually works, because Vue builds the whole DOM tree — #modal-root included — before anything is "open." But swap <div id="modal-root"> for something that only exists once a sibling component finishes its own async setup, or move ModalTrigger into a component that mounts earlier than the one defining #modal-root (a common shape with portals, layout slots, or anything behind v-if), and you get this in the console the moment ModalTrigger mounts:

Text
1
2
3
4
[Vue warn]: Failed to locate Teleport target with selector "#modal-root". Note the target element must exist before the component is mounted - i.e. the target cannot be rendered by the component itself, and ideally should be outside of the entire Vue component tree. [Vue warn]: Invalid Teleport target on mount: null (object)

Nothing renders. Not an error that stops your app — a warning, and the teleported content just silently doesn't appear. If you've ever debugged "the modal content is in my Vue Devtools component tree but not in the actual DOM," this is almost always why: Vue looked for the target, didn't find it yet, and gave up.

Here's the detail that explains both the bug and the fix: <Teleport> doesn't continuously watch for its target to appear. It resolves the target selector to a real DOM node exactly once, at a specific point in its own mount process, and if that lookup fails, it doesn't retry on its own.

The mental model: every component in your tree mounts in a fixed order — roughly, a parent sets up, then its children mount in template order, then the parent's own mount finishes. A <Teleport> is just one more thing that mounts at its position in that order. When ModalTrigger mounts, Vue runs querySelector('#modal-root') right then, synchronously, as part of mounting the Teleport. If #modal-root is a sibling that comes later in AppShell's template, it may not exist in the DOM yet at that exact instant — AppShell is still partway through mounting its own children. The failure isn't about whether the target element exists in your app at all; it's about whether it exists in the DOM at the single moment Vue checks.

This is also why the fix Vue shipped for this — the defer prop, added in 3.5 — is described in the official docs as working "similarly to the mounted lifecycle hook." A normal Teleport resolves its target during the synchronous mount pass, before any component's mounted hook has run. A deferred Teleport delays that same lookup until after the current render cycle — until the rest of the tree being mounted in this pass has had its chance to exist in the DOM, much like code in mounted() runs after the whole subtree is attached.

Stage 1 — the target exists, but too late. This is the exact shape from the problem section:

Vue
1
2
3
4
<Teleport to="#modal-root"> <div v-if="open" class="modal">Hello</div> </Teleport> <!-- #modal-root is defined in a sibling that mounts AFTER this one -->

Without any extra prop, Vue resolves #modal-root synchronously, right now, as part of mounting this Teleport. If the sibling that defines #modal-root hasn't mounted yet, the lookup fails and you get the warning from the problem section. Key concept: the content you're teleporting (open) being falsy doesn't matter — the target lookup happens at mount time regardless of whether anything is currently being teleported.

Stage 2 — add defer. This is the fix, available since Vue 3.5:

Vue
1
2
3
<Teleport defer to="#modal-root"> <div v-if="open" class="modal">Hello</div> </Teleport>

With defer, Vue doesn't give up on the first failed lookup. It waits until the current render cycle finishes — after every sibling in this same mount pass has had a chance to render — and tries the selector again. In the AppShell example, by the time that second lookup runs, #modal-root exists, because AppShell's full template has finished mounting. Key concept: defer doesn't make Teleport reactive to the target appearing at any future point — it buys exactly one extra chance, timed to land after the surrounding tree's initial mount.

Stage 3 — why that one extra chance needed its own bug-fix history. defer sounds like it should have been a clean, one-PR feature: delay a lookup, try again. In practice, "after the current render cycle" interacts with nearly every other thing Vue schedules around mounting, and getting it right took far more than one pass. Reading straight through the vuejs/core changelog for Teleport-related fixes since defer shipped:

VersionFixes
3.5.12A disabled Teleport combined with updateCssVars (#12113)
3.5.13Deferred Teleport updates arriving before mounted (#12168)
3.5.15Deferred Teleport updates arriving both before and after mount (#13350)
3.5.21Hydrating a disabled Teleport with an undefined target (#11235)
3.5.22Multiple Teleports mounting correctly inside a custom element with shadowRoot: false (#13900)
3.5.28Handling of the disabled Teleport's target anchor (#14417)
3.5.31Deferring Teleport mount/update until Suspense resolves (#8619)
3.5.32Teleport updates that arrive before its own deferred mount completes (#14642)
3.5.33Stop moving a Teleport's children when the Teleport itself isn't mounted (#14702)
3.5.39Nested async Teleport content resolving correctly under SSR (#9431)
3.5.44Child-node ordering around targetStart/targetAnchor (#15818)

Eleven patches, across more than a year of 3.5.x point releases, and the most recent one landed in the version this article verifies against. Key concept: this isn't a sign that defer is fragile or that you should avoid it — it's a sign of how many real, independent timing paths a single "try again later" feature has to account for: Suspense boundaries that resolve asynchronously, SSR hydration reconciling server-rendered markup against a client mount, disabled Teleports that have no target to resolve at all, and nested component trees unmounting mid-flight. Each row above is a real, reproducible scenario someone hit in production and filed. The practical upshot: defer genuinely fixes the ordering problem from Stage 1, and by 3.5.44 the surrounding edge cases around it are, as far as the project's own issue tracker shows, settled — but it took iterating against real usage, not a single clean implementation, to get there.

  • defer only works within the same mount/update cycle. It is not a poll, a MutationObserver, or a retry-forever mechanism. If the target element is added a tick later — inside a setTimeout, after an unrelated async operation, or from code outside Vue entirely — defer will not find it, and you get the exact same "Invalid Teleport target" warning as without defer. The fix is for ordering within one render, not for a target that doesn't exist yet at all.
  • defer plus Suspense needed a dedicated fix. If your Teleport sits inside a component wrapped in <Suspense>, the mount/update itself has to wait for the async dependency to resolve before defer's "try again" logic even makes sense — that's specifically what 3.5.31 (PR #8619) addressed. If you're on an older 3.5.x patch and see a deferred Teleport inside Suspense behave inconsistently, upgrading is the first thing to try before debugging your own code.
  • A :disabled Teleport still needs a valid target expression, even if it never activates. Several of the patches above (3.5.12, 3.5.21, 3.5.28) are specifically about the disabled state interacting badly with target resolution or hydration. Don't assume disabled sidesteps target-lookup bugs entirely; test the disabled path too if your Teleport toggles disabled dynamically.
  • Multiple Teleports targeting the same element render in mount order, not template order. If two different components each teleport into #modal-root, whichever one mounts first lands first in the DOM. This isn't something defer changes — it's an orthogonal fact worth knowing if you ever stack more than one Teleport onto a shared container and the visual order looks unexpected.
  • SSR has its own resolution path, not the same as client-side mount. The 3.5.39 fix (PR #9431) was specifically about nested async Teleport content during server rendering — a separate code path from the browser's synchronous querySelector lookup described in the mental model above. If you're teleporting content from a component that's itself inside an async <Suspense> boundary during SSR, verify on 3.5.39 or later.

  • Default to defer whenever the target might be a sibling rendered later in the same tree, or whenever you're not in full control of render order (layout slots, portals provided by a parent, conditionally-mounted containers). It costs nothing when the target already exists early, and it's the documented fix for exactly this failure mode.
  • Don't reach for defer as a substitute for guaranteeing the target exists. If the container element is itself behind an async condition that might resolve after the current mount pass — a fetched layout, a lazily-loaded route — defer won't help. Either hoist the target markup above the async boundary, or hold off rendering the Teleport (v-if) until you know the target is present.
  • Stay on a recent 3.5.x patch. Given how many of the fixes above are genuinely separate scenarios rather than regressions of each other, an old 3.5.x pin (anything before .39, in particular) is leaving real, already-fixed Teleport bugs on the table for no reason.
  • Test the :disabled toggle path explicitly, not just the "always enabled" happy path, if your Teleport's disabled prop is ever reactive. That's where several of the subtler fixes above lived.

No. It fixes the specific case where the target exists somewhere in the tree being mounted in the same render pass, just later than the Teleport itself. If the target is added by unrelated code after that pass completes — a later setTimeout, a separate async fetch that isn't part of the same mount — defer still fails the same way a non-deferred Teleport would.

Vue 3.5. It's documented in the Teleport guide and the built-in components API reference as a 3.5+ feature.

Because "retry the target lookup once, later" touches nearly every other thing Vue schedules around mounting: Suspense resolution, SSR hydration, the disabled state, nested unmounts, and multi-Teleport ordering inside shadow DOM. Each patch in the table above fixed one specific, independently-reported interaction — not the same bug reappearing.

Nothing in the public 3.6 release-candidate notes changes Teleport's resolution model as of this writing (3.6.0-rc.14). This article covers 3.5.x behavior, which is what every published 3.6 pre-release so far still runs for this feature.

It's a reasonable default for any Teleport whose target isn't guaranteed to exist before the Teleport itself mounts, and the cost of adding it when it isn't strictly needed is effectively zero. It is not, however, a reason to stop thinking about where the target actually lives.

No — defer's one-time retry is a mount/render-cycle concern specific to Teleport's target resolution, not the shared reactivity notify buffer that ref, computed, and watch all queue through. If that separate mechanism is unfamiliar, Vue's reactivity batch and flush() loop covers it — useful context, not required here.

SituationWhat to do
Target is a static element always present before the Teleport mountsNo defer needed
Target is a sibling that renders later in the same mount passAdd defer
Target may not exist until an unrelated async event laterdefer won't help — gate the Teleport with v-if instead
Teleport is inside <Suspense>Needs 3.5.31+ for correct defer interaction
Teleport ever toggles :disabledVerify on 3.5.28+ for correct anchor handling
Teleporting during SSR with nested async contentNeeds 3.5.39+
Multiple Teleports share one targetThey render in mount order, not template order
Vue
1
2
3
4
<!-- The safe default when you're not 100% sure the target exists yet --> <Teleport defer to="#modal-root"> <div v-if="open" class="modal">…</div> </Teleport>

Runs right in your browser — poke at it and watch the concept react live.

  • <Teleport> resolves its target selector exactly once, synchronously, at mount time — it does not watch for the target to appear later.
  • The classic failure is a target that's a sibling rendered after the Teleport in the same component tree; defer (Vue 3.5+) fixes this by retrying the lookup once, after the current render cycle, similar in timing to the mounted hook.
  • defer is not a general "wait until the target exists" mechanism — it only covers the same mount/update cycle, not a target added later by unrelated code.
  • Getting defer right across Suspense, SSR, the disabled state, and multi-Teleport ordering took more than a dozen separate patches through 3.5.x, the most recent in 3.5.44 — treat an old 3.5 pin as leaving real fixes unclaimed.
  • Multiple Teleports sharing one target land in mount order, not template order — an orthogonal fact worth knowing even when defer isn't in play.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

A target that exists in your app and a target that exists in the DOM at the exact moment Vue looks for it are two different claims — defer closes the gap between them for one common case, not for all of them. The next time a Teleport "just doesn't show up," check mount order before you check your own code.

Have you hit the "Invalid Teleport target" warning in a layout you were sure was correct? Drop what finally fixed it in the comments.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.