Profiling and Memory Leaks

Vue 2 does not leak memory on its own — every leak traced in production Vue 2 apps comes from a resource acquired in created/mounted that outlives the component because nothing released it in beforeDestroy.

Common Leak Sources in Vue 2 Components

  • Global event listeners (window.addEventListener, EventBus.off in beforeDestroy
  • setInterval/setTimeout handles that keep firing and referencing this after the component unmounts
  • Third-party library instances (chart libraries, map libraries, editors) that hold DOM references and internal state — most expose a .destroy() method that must be called explicitly
  • IntersectionObserver/ResizeObserver/MutationObserver instances not disconnected
  • Vuex module subscriptions (store.subscribe) that aren't unsubscribed
  • Closures captured by long-lived singletons (like the EventBus from Module 3) that reference component methods
leak-cleanup-pattern.jsjavascript
export default {
  data: () => ({ pollHandle: null, resizeObserver: null }),
  mounted() {
    this.pollHandle = setInterval(this.pollStatus, 5000)
    this.resizeObserver = new ResizeObserver(this.handleResize)
    this.resizeObserver.observe(this.$el)
    window.addEventListener('beforeunload', this.warnUnsavedChanges)
  },
  beforeDestroy() {
    clearInterval(this.pollHandle)
    this.resizeObserver?.disconnect()
    window.removeEventListener('beforeunload', this.warnUnsavedChanges)
  },
  methods: {
    pollStatus() { /* ... */ },
    handleResize() { /* ... */ },
    warnUnsavedChanges(e) { /* ... */ }
  }
}

Bundle Optimization with Vite

Async components and Vite's Rollup-based production build combine to keep the initial JS payload small without manual chunk configuration in most cases.

async-component.jsjavascript
// Route-level splitting (Module 4) already creates a chunk boundary per route.
// Component-level splitting handles heavy widgets used conditionally within a route:
export default {
  components: {
    HeavyChartWidget: () => import('@/components/HeavyChartWidget.vue')
  }
}
vite.config.js (build analysis)javascript
import { defineConfig } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-vue': ['vue', 'vue-router', 'vuex'],
          'vendor-charts': ['chart.js'] // isolate large, infrequently-changed deps for long-term caching
        }
      },
      plugins: [visualizer({ filename: 'dist/stats.html', gzipSize: true })]
    }
  }
})

Tree-Shaking Checklist for Vue 2 + Vite

  • Import only the Vuex/Vue Router helpers used (mapState, mapActions) — these are already tree-shakeable named exports
  • Avoid importing entire utility libraries (e.g. import _ from 'lodash'); use lodash-es with named imports so Rollup can eliminate unused functions
  • Mark side-effect-free files in package.json ("sideEffects": false) for any custom internal packages/monorepo libs to let Rollup drop unused exports
  • Run vite build with the visualizer plugin periodically and check for accidental duplicate dependency versions inflating bundle size

Technical SEO Considerations

A pure client-side-rendered Vue 2 SPA serves an near-empty <div id="app"></div> on first byte — content only appears after JS execution. Modern Googlebot does render JS, but rendering is queued and delayed, and other crawlers (social media link previews, some secondary search engines) often don't execute JS at all.

ApproachSEO OutcomeComplexity
Pure CSR (default Vite SPA)Weakest — content indexed only after delayed JS render pass; no content for non-JS crawlers/social previewsLowest
Prerendering (e.g. prerender-spa-plugin / vite-plugin-prerender)Good for mostly-static marketing pages — HTML snapshot generated at build time per routeLow-medium — only works for routes with content known at build time
SSR (Nuxt-style or custom vue-server-renderer setup)Strongest — full HTML on first response for every route, including dynamic/authenticated contentHighest — requires a Node server, careful handling of browser-only APIs

Practical Example: Chart Widget with Strict Memory Cleanup

HeavyChartWidget.vuevue
<template>
  <div class="chart-widget">
    <div v-if="!isVisible" ref="sentinel" class="chart-widget__sentinel" />
    <canvas v-else ref="canvas" class="chart-widget__canvas" />
  </div>
</template>

<script>
let ChartJsModulePromise = null

export default {
  name: 'HeavyChartWidget',
  props: {
    dataset: { type: Array, required: true }
  },
  data: () => ({
    isVisible: false,
    chartInstance: null,
    observer: null
  }),
  mounted() {
    // Lazy-load the chart only when the widget scrolls into view —
    // avoids paying Chart.js's bundle cost for users who never see it.
    this.observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        this.isVisible = true
        this.observer.disconnect()
        this.$nextTick(this.initChart)
      }
    })
    this.observer.observe(this.$refs.sentinel)
  },
  beforeDestroy() {
    // Strict cleanup profile: every acquired resource has a matching release.
    this.observer?.disconnect()
    this.chartInstance?.destroy() // Chart.js exposes .destroy() to release canvas context + listeners
  },
  watch: {
    dataset: {
      deep: true,
      handler(newData) {
        if (!this.chartInstance) return
        this.chartInstance.data.datasets[0].data = newData
        this.chartInstance.update()
      }
    }
  },
  methods: {
    async initChart() {
      if (!ChartJsModulePromise) {
        ChartJsModulePromise = import('chart.js/auto')
      }
      const { Chart } = await ChartJsModulePromise
      this.chartInstance = new Chart(this.$refs.canvas, {
        type: 'line',
        data: {
          labels: this.dataset.map((d) => d.label),
          datasets: [{ label: 'Value', data: this.dataset.map((d) => d.value) }]
        },
        options: { responsive: true, maintainAspectRatio: false }
      })
    }
  }
}
</script>

<style scoped>
.chart-widget { min-height: 240px; }
.chart-widget__sentinel { height: 240px; }
.chart-widget__canvas { width: 100%; height: 240px; }
</style>