A Vue 3 composable awaits a config fetch and then registers onUnmounted to close a WebSocket, and sockets stay open after navigation; why, and how do you fix it?
answer
- cleanup registered too late
- composable's own await is not restored
- register first, act later
- unmounted before the fetch resolves
basics
~20 sThe onUnmounted call runs after the composable's own await, when Vue 3 has no current instance, so it is never registered and the socket never closes. Register onUnmounted first, and let it close or cancel whatever exists.
solid answer
~50 sLifecycle hooks attach to the component instance that is current while setup runs synchronously. The composable is called from setup, but its first `await` returns control, and the code after it — `new WebSocket(...)` and `onUnmounted(() => socket.close())` — runs in a later microtask with no current instance. Development builds warn that `onUnmounted` is called with no active component instance; production silently drops it, so every visit leaks a socket. Awaiting the composable at the top level of `<script setup>` does not help: only the component's own top-level awaits are context-restored, not awaits inside the composable. The fix is to register cleanup synchronously at the start of the composable, keep the socket in a variable the cleanup can see, set an `unmounted` flag, and abort the fetch with an `AbortController` so a socket is never opened for a component that is already gone.
code
ts · 29 lines// useLiveFeed.ts
import { ref, onUnmounted } from 'vue'
export function useLiveFeed(url: string) {
const messages = ref<string[]>([])
const controller = new AbortController()
let socket: WebSocket | null = null
let unmounted = false
// Registered synchronously: attached to the calling component
onUnmounted(() => {
unmounted = true
controller.abort()
socket?.close()
})
const ready = fetch(url, { signal: controller.signal })
.then(r => r.json())
.then((config: { socketUrl: string }) => {
if (unmounted) return // component left before the fetch resolved
socket = new WebSocket(config.socketUrl)
socket.onmessage = e => messages.value.push(String(e.data))
})
.catch(err => {
if (err.name !== 'AbortError') throw err
})
return { messages, ready }
}go deeper
Recall that onUnmounted must be called before any await in a composable, or the cleanup is never attached.
Explain why the composable's own await loses the current instance even when the component awaits it at the top level of script setup.
Restructure the composable to register cleanup first, guard against unmount mid-fetch with a flag and AbortController, and audit other async composables for the same shape.
Make register-first, work-later a documented rule for async composables and back it with tests that mount and unmount to surface lost-hook warnings.
## The symptom A `useLiveFeed(url)` composable fetches connection settings, opens a `WebSocket` and registers cleanup: ```ts export async function useLiveFeed(url: string) { const config = await fetch(url).then(r => r.json()) const socket = new WebSocket(config.socketUrl) onUnmounted(() => socket.close()) return socket } ``` A dashboard page calls it. After users navigate back and forth, the browser shows dozens of open sockets and the server sees duplicate subscribers. In development the console shows `onUnmounted is called when there is no active component instance to be associated with.` ## Why the cleanup is never registered Vue 3 attaches a lifecycle hook to the **current instance** — an internal variable set while the component's `setup()` runs **synchronously** and reset as soon as it returns. 1. The page's setup calls `useLiveFeed()`. The composable starts `fetch()` and hits `await`. 2. The `async` composable returns a Promise at that point; setup carries on and finishes; Vue resets the current instance. 3. When the fetch resolves, the rest of the composable runs in a microtask. This component is no longer current — normally no instance is. 4. `onUnmounted(...)` has nothing to attach to. Development builds warn; production silently ignores it. 5. The socket is open and nobody will ever close it. ## Why top-level await does not rescue it In `<script setup>`, `const feed = await useLiveFeed(url)` is a top-level await, and the compiler does restore the instance **after that await resolves** — for the code that follows in the component. It does not reach **inside** the composable: the composable's own `await fetch(...)` is inside a function, which the rewrite never touches. So the component's later code is fine while the composable's `onUnmounted` is still lost. | Where `onUnmounted` is called | Registered? | |---|---| | Composable body, before its first `await` | Yes | | Composable body, after its own `await` | **No** | | Component `<script setup>`, after a top-level `await` | Yes | | Inside the fetch's `.then()` callback | **No** | ## The fix: register first, act later Move every hook to the synchronous start of the composable and let the cleanup handle whatever state exists at unmount time: - **Register `onUnmounted` before any async work.** The callback closes over a `let socket` that may still be `null`. - **Track unmounting.** Set an `unmounted` flag in the cleanup, and check it before opening the socket, so a component that disappears mid-fetch never gets one. - **Abort the pending request.** An `AbortController` whose `abort()` is called in the cleanup stops the fetch itself. - **Expose readiness instead of awaiting internally.** Return a `ready` promise so a caller that needs the data can still await it at the top level of `<script setup>`. ## Other fixes and why they are weaker - **`getCurrentInstance()` captured before the await and passed as `onUnmounted`'s optional `target` argument** does attach the hook, but it leans on an internal instance object that application code is not meant to depend on, and it still opens a socket for an unmounted component. - **Moving the fetch into `onMounted`** avoids the await in setup, but the socket still has to be closed by a hook registered synchronously. - **Relying on the component's own cleanup** puts composable internals back into every consumer — the leak returns wherever someone forgets. ## Proving the fix in a test A unit test can pin the behaviour so it cannot regress: 1. Stub `fetch` and `WebSocket` with fakes that record calls. 2. Mount a small component that calls `useLiveFeed`, then unmount it **before** resolving the fetch — the fake socket must never be constructed. 3. Mount again, resolve the fetch, then unmount — the fake socket's `close()` must have been called once. 4. Fail the test on any console warning, which catches a hook registered with no active instance. ## Checking for the same bug elsewhere - Search for `async function use` and look for any `on*` hook, `watch` or `watchEffect` after an `await` inside it; watchers created there are not stopped automatically either. - Watch for the dev warning in tests; mounting and unmounting the component in a unit test surfaces it. - Check browser tools for sockets or intervals that outlive navigation. The principle: in Vue 3, everything that ties a resource to a component's lifetime must be registered synchronously; only the work may be asynchronous.
- If the component calls await useLiveFeed(url) at the top level of <script setup>, is the composable's hook registered?No, not in the async version. The compiler restores the instance after the component's top-level await resolves, but the composable's internal `await fetch()` is inside a function and is not rewritten. Code after it, including `onUnmounted`, runs with no current instance. Only code after the top-level await in the component itself gets the restored context.
- Why keep the unmounted flag when the fetch is already aborted?Abort only affects a request still in flight. If the response arrived and parsing or the next `.then()` is already queued when the component unmounts, abort has nothing to cancel, and the socket would be opened anyway. The flag closes that window by refusing to open a socket once cleanup has run.
saying these in an interview costs you the question
- Awaiting the composable at top level of script setup restores its internal hooks.
- The hook is registered but runs late, so the socket closes eventually.
- Vue closes WebSockets opened in a composable automatically on unmount.
- Moving the fetch into onMounted alone fixes the leak.
- Production builds warn about the lost hook, so leaks are easy to spot.