skip to content

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?

level: seniorimportance: should knowfreq 34%

answer

  1. cleanup registered too late
  2. composable's own await is not restored
  3. register first, act later
  4. unmounted before the fetch resolves

basics

~20 s

The 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 s

Lifecycle 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
ts
// 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

for a junior

Recall that onUnmounted must be called before any await in a composable, or the cleanup is never attached.

for a middle

Explain why the composable's own await loses the current instance even when the component awaits it at the top level of script setup.

for a senior

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.

for a principal

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.