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
thisafter 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
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.
// 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')
}
}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'); uselodash-eswith 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 buildwith 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.
| Approach | SEO Outcome | Complexity |
|---|---|---|
| Pure CSR (default Vite SPA) | Weakest — content indexed only after delayed JS render pass; no content for non-JS crawlers/social previews | Lowest |
| Prerendering (e.g. prerender-spa-plugin / vite-plugin-prerender) | Good for mostly-static marketing pages — HTML snapshot generated at build time per route | Low-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 content | Highest — requires a Node server, careful handling of browser-only APIs |
Practical Example: Chart Widget with Strict Memory Cleanup
<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>