A Vue 3 component creates a watch() after an await inside onMounted, and it keeps firing after the component unmounts; why, and how do you fix it?
answer
- which scope is active
- setup-time collection
- the continuation has no instance
- make the logic conditional
basics
~20 sWatchers are bound to the component only when created while its scope is active, which ends at the first await. The late watcher belongs to no scope, so unmount never stops it. Create it synchronously with a condition, or keep its handle and stop it.
solid answer
~50 sVue binds a watcher to a component by collecting it into the component's effect scope, which is active only while `setup` and each lifecycle hook run synchronously. `onMounted(async () => { await loadConfig(); watch(theme, ...) })` creates the watcher in a promise continuation, when no scope is active, so the component's unmount never stops it. If its source outlives the component - shared or module-level state - it keeps firing and keeps the closure, and whatever it captured, alive. The fix the docs recommend is to create the watcher synchronously and make its logic conditional: store the loaded data in a ref and have the watcher do nothing until it is set. Otherwise keep the returned handle and call it on unmount. One exception: a top-level `await` in `<script setup>` is compiled so the instance is restored afterwards, so watchers created after it are bound.
code
vue · 21 lines<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
interface Config { contrast: boolean }
declare function loadConfig(): Promise<Config>
declare function applyTheme(theme: string, config: Config): void
declare const theme: import('vue').Ref<string> // shared, outlives this component
const config = ref<Config | null>(null)
onMounted(async () => {
config.value = await loadConfig()
})
// created synchronously: bound to this component, stopped on unmount
watch([theme, config], ([t, c]) => {
if (c) applyTheme(t, c)
})
</script>
<template><slot /></template>go deeper
Remember the rule from the docs: create watchers synchronously in setup; ones created in async callbacks must be stopped manually.
Explain the mechanism: effects are collected by the active component scope, and that scope is only active during synchronous setup and hook execution.
Find the leak from its symptoms - effects firing after navigation, growing per mount - fix it with a conditional synchronous watcher, and cover the unmount-before-load race.
Set a codebase rule that effects are created at setup time, with late-created effects wrapped in an owned scope, so lifetimes are visible in review.
## How Vue ties a watcher to a component A Vue 3 component owns an **effect scope**. While the component's `setup()` (or `<script setup>`) runs, and while each of its lifecycle hooks runs, Vue makes that scope **active**. Any reactive effect created during that time - a `watch`, a `watchEffect`, a `computed` - is pushed into the active scope. When the component unmounts, Vue stops the scope, which stops every effect in it and runs their cleanups. The key word is *while*. The scope is active only during the **synchronous** execution of setup or the hook. The docs put it plainly: a watcher must be created synchronously to be bound; one created in an async callback is not bound and must be stopped manually. ## The leaking shape ```ts onMounted(async () => { const config = await loadConfig() watch(theme, (t) => applyTheme(t, config)) }) ``` 1. `onMounted` runs the hook with the component's scope active. 2. The hook reaches `await` and returns a promise; Vue restores the previous state, so no component scope is active any more. 3. `loadConfig()` resolves; the rest of the function runs in a microtask. 4. `watch(...)` finds **no active scope**, so the watcher is collected by nothing. 5. On unmount, the component's scope is stopped - and this watcher is not in it. The same happens inside `setTimeout`, a `.then()` callback, an event listener, or after an `await` in a plain `setup()` function. ## What the leak actually costs - If the watched source **outlives** the component - a module-level ref, shared composable state, a store - the source keeps a subscription to the watcher. It keeps firing after unmount (calling `applyTheme` for a component that is gone) and keeps its closure alive, including `config` and anything else it captured. - Mount and unmount the component repeatedly and you accumulate one extra watcher per mount: duplicated side effects, growing memory. - If the source is **local** to the component, the watcher and its source usually become unreachable together, so the visible symptom may never appear - which is why the bug survives review until the source is moved into shared state. ## Fix 1: create it synchronously, make it conditional The docs' recommendation is to avoid async creation entirely: ```ts const config = ref<Config | null>(null) onMounted(async () => { config.value = await loadConfig() }) watch([theme, config], ([t, c]) => { if (c) applyTheme(t, c) }) ``` The watcher is created during setup, so it is bound and stopped on unmount, and it simply does nothing until the data exists. ## Fix 2: keep the handle and stop it When a watcher genuinely has to be created later, store its handle and stop it when the component goes away: ```ts let stopThemeWatch: (() => void) | undefined onMounted(async () => { const config = await loadConfig() stopThemeWatch = watch(theme, (t) => applyTheme(t, config)) }) onUnmounted(() => stopThemeWatch?.()) ``` This works, but it is easy to get wrong - for example if the component unmounts *before* `loadConfig()` resolves, the watcher is created after unmount and nothing stops it. Guard for that, or prefer Fix 1. For a group of late effects, running them inside an effect scope you control and stopping the scope is the tidier variant. ## The `<script setup>` exception A **top-level** `await` in `<script setup>` is special. The SFC compiler wraps it with an internal helper that restores the current component instance after the await, so a watcher created on the next line **is** bound to the component. This applies only to top-level awaits in `<script setup>`; an `await` inside any function you write - a hook, a handler, a helper - gets no such treatment. ## Review checklist | Where the watcher is created | Bound to the component? | |---|---| | Top level of `<script setup>` or `setup()` | yes | | After a top-level `await` in `<script setup>` | yes (instance restored by the compiler) | | Synchronously inside a lifecycle hook | yes | | After an `await` inside a hook or function | no | | Inside `setTimeout`, `.then()`, an event listener | no |
- Why does a watcher created after a top-level await in Vue 3 `<script setup>` not leak?The SFC compiler rewrites each top-level `await` so that the current component instance is restored when the awaited value resolves. The instance's scope is active again on the next line, so a watcher created there is collected and stopped on unmount. An `await` inside a function you write is not rewritten, so that rule does not extend to hooks or handlers.
- With Fix 2 in Vue 3, what goes wrong if the component unmounts before the async load finishes?The unmount hook runs while the handle variable is still undefined, so it stops nothing; then the load resolves and creates the watcher for a component that is already gone. Either check a flag set on unmount before creating the watcher, or avoid late creation by making a synchronously created watcher conditional on the loaded data.
saying these in an interview costs you the question
- Any watcher created inside a component file is stopped when the component unmounts.
- Creating the watcher inside onMounted guarantees it is bound, even after an await.
- A top-level await in <script setup> always breaks watcher binding.
- Vue logs a warning whenever a watcher is created outside a component.
- A leaked watcher is harmless because it only reads reactive state.