How to fix memory leaks in Vue

Memory leaks occur when components retain references to objects after unmounting, causing memory consumption to grow and application performance to degrade over time. As the creator of CoreUI, a widely used open-source UI library, I’ve debugged and prevented memory leaks in Vue applications throughout my 11 years of frontend development. The most systematic approach is properly cleaning up event listeners, timers, watchers, and subscriptions in onBeforeUnmount lifecycle hook. This method ensures components release resources when destroyed, preventing memory accumulation during navigation and preventing browser slowdowns in long-running applications.

Clean up event listeners, intervals, watchers, and subscriptions in onBeforeUnmount to prevent memory leaks.

<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'

// Potential memory leak: Global event listener
const windowWidth = ref(window.innerWidth)

// BAD: Without cleanup
onMounted(() => {
  window.addEventListener('resize', handleResize)
})

// GOOD: With cleanup
let resizeHandler
onMounted(() => {
  resizeHandler = () => {
    windowWidth.value = window.innerWidth
  }
  window.addEventListener('resize', resizeHandler)
})

onBeforeUnmount(() => {
  // Clean up event listener
  window.removeEventListener('resize', resizeHandler)
})

// Potential memory leak: Timer
const counter = ref(0)
let intervalId

onMounted(() => {
  // BAD: Interval continues after component unmounts
  intervalId = setInterval(() => {
    counter.value++
  }, 1000)
})

onBeforeUnmount(() => {
  // GOOD: Clear interval
  if (intervalId) {
    clearInterval(intervalId)
  }
})

// Potential memory leak: Watcher
const searchQuery = ref('')
let unwatch

onMounted(() => {
  // Watchers created with watch() return stop function
  unwatch = watch(searchQuery, (newValue) => {
    console.log('Search query changed:', newValue)
  })
})

onBeforeUnmount(() => {
  // GOOD: Stop watcher
  if (unwatch) {
    unwatch()
  }
})
</script>

Common memory leak patterns and fixes:

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import EventBus from './eventBus'
import axios from 'axios'

// 1. Event Bus listeners
const message = ref('')

onMounted(() => {
  // Subscribe to event bus
  EventBus.on('notification', handleNotification)
})

function handleNotification(data) {
  message.value = data.message
}

onBeforeUnmount(() => {
  // IMPORTANT: Unsubscribe from event bus
  EventBus.off('notification', handleNotification)
})

// 2. API request cancellation
let abortController

async function fetchData() {
  // Create new AbortController for each request
  abortController = new AbortController()

  try {
    const response = await axios.get('/api/data', {
      signal: abortController.signal
    })
    return response.data
  } catch (error) {
    if (axios.isCancel(error)) {
      console.log('Request cancelled')
    }
    throw error
  }
}

onBeforeUnmount(() => {
  // Cancel pending requests
  if (abortController) {
    abortController.abort()
  }
})

// 3. Third-party library cleanup
let chartInstance

onMounted(() => {
  // Initialize chart library
  chartInstance = new Chart(document.getElementById('chart'), {
    type: 'line',
    data: chartData
  })
})

onBeforeUnmount(() => {
  // Destroy chart instance
  if (chartInstance) {
    chartInstance.destroy()
    chartInstance = null
  }
})

// 4. DOM element references
const elementRef = ref(null)
let observer

onMounted(() => {
  // Create IntersectionObserver
  observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        console.log('Element is visible')
      }
    })
  })

  if (elementRef.value) {
    observer.observe(elementRef.value)
  }
})

onBeforeUnmount(() => {
  // Disconnect observer
  if (observer) {
    observer.disconnect()
    observer = null
  }
})

// 5. WebSocket connections
let ws

onMounted(() => {
  ws = new WebSocket('ws://localhost:8080')

  ws.onmessage = (event) => {
    console.log('Message received:', event.data)
  }
})

onBeforeUnmount(() => {
  // Close WebSocket connection
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.close()
  }
})
</script>

<template>
  <div>
    <div ref='elementRef'>Observed element</div>
    <p>{{ message }}</p>
    <p>Counter: {{ counter }}</p>
    <canvas id='chart'></canvas>
  </div>
</template>

Detecting memory leaks with Chrome DevTools:

<script setup>
// Test component for memory leak detection
import { ref, onMounted, onBeforeUnmount } from 'vue'

const data = ref([])
let intervalId

onMounted(() => {
  // Simulate memory leak
  intervalId = setInterval(() => {
    // Continuously adding data without cleanup
    data.value.push({
      id: Date.now(),
      content: 'x'.repeat(10000) // Large string
    })
  }, 100)
})

// MISSING: No cleanup causes memory leak
// onBeforeUnmount(() => {
//   clearInterval(intervalId)
//   data.value = []
// })
</script>

Steps to detect memory leaks:

  1. Open Chrome DevTools
  2. Go to Memory tab
  3. Take heap snapshot
  4. Navigate to component and back multiple times
  5. Take another heap snapshot
  6. Compare snapshots - component instances should be garbage collected
  7. If detached DOM nodes or component instances remain, investigate

Composable for automatic cleanup:

// useCleanup.js
import { onBeforeUnmount } from 'vue'

export function useCleanup() {
  const cleanupTasks = []

  function addCleanup(fn) {
    cleanupTasks.push(fn)
  }

  function addInterval(callback, delay) {
    const id = setInterval(callback, delay)
    addCleanup(() => clearInterval(id))
    return id
  }

  function addTimeout(callback, delay) {
    const id = setTimeout(callback, delay)
    addCleanup(() => clearTimeout(id))
    return id
  }

  function addEventListener(target, event, handler, options) {
    target.addEventListener(event, handler, options)
    addCleanup(() => target.removeEventListener(event, handler, options))
  }

  onBeforeUnmount(() => {
    cleanupTasks.forEach(fn => fn())
  })

  return {
    addCleanup,
    addInterval,
    addTimeout,
    addEventListener
  }
}

Using cleanup composable:

<script setup>
import { ref } from 'vue'
import { useCleanup } from './useCleanup'

const cleanup = useCleanup()
const count = ref(0)

// Automatically cleaned up
cleanup.addInterval(() => {
  count.value++
}, 1000)

cleanup.addEventListener(window, 'resize', () => {
  console.log('Window resized')
})

cleanup.addCleanup(() => {
  console.log('Custom cleanup logic')
})
</script>

Here the onBeforeUnmount lifecycle hook executes cleanup functions before component destruction. Event listeners must be removed with removeEventListener using same handler reference. Timers require clearInterval or clearTimeout with timer ID to stop execution. Watchers return stop function that should be called to prevent watcher execution. Third-party libraries often provide destroy or dispose methods for cleanup. AbortController cancels pending HTTP requests when component unmounts. The useCleanup composable centralizes cleanup logic for reusability across components.

Best Practice Note:

This is the memory leak prevention strategy we implement in CoreUI Vue components to ensure stable performance in long-running applications. Use Chrome DevTools Memory profiler regularly during development to catch memory leaks early, implement strict cleanup patterns for all subscriptions and listeners, leverage Vue DevTools to track component lifecycle and detect components not being destroyed, and consider using weakRef or weakMap for caching when strong references might cause leaks.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author