To use this site please enable javascript on your browser! The Hidden Reason Your Vue Watchers Leak Memory (and How to Avoid It)

We use cookies, as well as those from third parties, for sessions in order to make the navigation of our website easy and safe for our users. We also use cookies to obtain statistical data about the navigation of the users.

See Terms & Conditions

The Hidden Reason Your Vue Watchers Leak Memory (and How to Avoid It)

by Bryce Andy 5 hours ago

A few weeks ago, a junior developer on my team wired an API call directly into a Vue watcher. Everything seemed fine...until the browser tab sat open for an afternoon.
Network usage crept up, the page slowed, and a memory leak started to show.

Instead of swooping in with a one-line fix, I let them trace the problem themselves. It became a teaching moment and a reminder of how deceptively dangerous watchers can be.

Screenshot of a Web Page with Memory Leak
Screenshot of a Web Page with Memory Leak

A Vue component on your app might be leaking memory and you have no idea, or worse...it might have happened on production and your clients are seeing the graphic above.

Why Watchers Leak

Watchers feel magical: “when X changes, do Y.”
But that magic can quietly:

  • Keep old references alive if you create closures or attach listeners inside the callback.

  • Spawn multiple API requests that never resolve or get cancelled.

  • Re-register the same side effect every time the component re-renders.

A single un-cleaned interval, event listener, or promise chain can add up to megabytes of retained memory. Let's see some examples;

1. Closures that Keep Old References Alive

Problem
A watcher captures a big object inside its callback. Each change creates a new closure that still references the previous object.

<script setup lang="ts">
import { ref, watch } from 'vue'

const hugeObject = ref({ items: Array(10_000).fill('data') })

watch(hugeObject, (val) => {
  // Every change creates a closure holding the *old* val
  someAsyncProcess(() => console.log(val.items.length))
})
</script>

Because the callback passed to someAsyncProcess never gets cleared, the old val stays in memory.

Cleanup
Return a stop handle and cancel or remove the work in onUnmounted:

import { ref, watch, onUnmounted } from 'vue'

const hugeObject = ref({ items: Array(10_000).fill('data') })

const stop = watch(hugeObject, (val, _, onCleanup) => {
  const handler = () => console.log(val.items.length)
  someAsyncProcess(handler)

  // Vue gives us onCleanup to run when watcher invalidates
  onCleanup(() => cancelAsyncProcess(handler))
})

onUnmounted(stop)

2. Multiple API Requests that Never Resolve

Problem
A value changes quickly; each change triggers a fetch without cancelling the previous one. This is the most common symptom for memory leak issues in Vue watchers.

const query = ref('')

watch(query, async (q) => {
  await fetch(`/api/search?q=${q}`)
})

If the user types fast, many requests stay alive, holding memory and possibly updating out of order.

Cleanup
Use an AbortController or an external library that supports cancellation(for example, Axios' CancelToken):

import { ref, watch } from 'vue'

const query = ref('')
let currentController: AbortController | null = null

watch(query, async (q, _, onCleanup) => {
  // Cancel any in-flight request
  currentController?.abort()
  currentController = new AbortController()

  try {
    await fetch(`/api/search?q=${q}`, { signal: currentController.signal })
  } catch (e) {
    if (e.name !== 'AbortError') throw e
  }

  onCleanup(() => currentController?.abort())
})

3. Event Listeners Registered on Every Change

Problem
Attaching listeners inside a watcher without removing them leads to stacked handlers.

const enableResizeTracking = ref(false)

watch(enableResizeTracking, (enabled) => {
  if (enabled) {
    window.addEventListener('resize', reportSize)
  }
})

Toggling enableResizeTracking repeatedly adds more and more listeners.

Cleanup

import { ref, watch } from 'vue'

const enableResizeTracking = ref(false)

watch(enableResizeTracking, (enabled, _, onCleanup) => {
  if (enabled) {
    window.addEventListener('resize', reportSize)
    onCleanup(() => window.removeEventListener('resize', reportSize))
  }
})

onCleanup runs whenever the watcher re-triggers or when it’s stopped, guaranteeing only one active listener.

Clues You’re Leaking Memory

  • Growing heap in Chrome DevTools even when nothing is happening.

  • Lingering network activity after navigating away.

  • Performance degradation over hours or after several route changes.

I encourage teammates to reproduce these issues: open Performance tab, take heap snapshots, watch retained objects. Seeing the leak live is far more powerful than a code review comment.

General Pattern to Teach Your Team

If a watcher starts something (listener, request, timer), it must also stop it.

Encourage developers to:

  • Always use the third argument (_, __, onCleanup) in a watcher.

  • Stop the watcher with the handle returned by watch when the component unmounts if it’s long-lived.

  • Think of watchers as resources with a lifecycle, not just callbacks.

These practical examples both demonstrate the leak and show the idiomatic Vue 3 cleanup strategy.

Bonus: onWatcherCleanup in Vue 3.5+

Vue 3.5 introduced a small but welcome enhancement to watcher cleanup.

onWatcherCleanup lets you register a teardown callback inside a watcher or watchEffect, without relying on the older positional onCleanup argument.

import { watch, onWatcherCleanup } from 'vue'

const userId = ref(0)

watch(userId, (id) => {
  const controller = new AbortController()

  fetch(`/api/user/${id}`, { signal: controller.signal })

  // Register cleanup *inside* the watcher
  onWatcherCleanup(() => controller.abort())
})

You must call it synchronously within the watcher’s callback or effect function, before any await or other async boundary.

Use onWatcherCleanup whenever you start something inside a watcher that needs explicit teardown like aborting a fetch, clearing an interval, or removing an event listener.
It’s the same leak-prevention power as onCleanup, just with a cleaner and more discoverable API.

Takeaway

Watchers are powerful, but unmanaged side effects turn them into silent memory leaks.
Whether you’re leading a team or writing solo, treat every watcher as a resource you must clean up.

Your users and your Chrome task manager will thank you.

Updated 5 hours ago

If you like this content, please consider buying me coffee.
Thank you for your support!

Become a Patron!