10 Vue Performance Mistakes That Only Show Up at Scale
Hossein HezamiDEV Community
1 views
Your Vue app feels fast when it has twenty components, one list, and a handful of API calls. Then the enterprise customer shows up with 80,000 rows, twelve nested filters, a realtime feed, and a dashboard that has been growing organically for three years.
That is when the bill arrives.
The frustrating part is that most large-scale Vue performance problems are not caused by Vue being slow. They are caused by patterns that are perfectly reasonable at small scale becoming expensive when the amount of data, components, watchers, and user interactions grows.
A form watcher is harmless until it watches a 4,000-field object. A table is harmless until you render every row. A global store is harmless until half the application re-evaluates when a dropdown changes.
Here are ten Vue performance mistakes that tend to show up only when the app is already under real load.
TL;DR
The most common large-scale Vue performance problems come from:
Rendering more DOM than the user can see
Making huge API payloads deeply reactive
Watching too much state
Building components that are too large
Using unstable list keys
Doing expensive work in computed properties
Putting temporary UI state into global stores
Loading everything eagerly
Shipping too much SSR/hydration state
Forgetting to clean up listeners and subscriptions
📋 Table of Contents
Rendering every row when the user only sees twenty
Making giant API payloads deeply reactive
Deep watchers that watch the entire world
One giant component that re-renders the whole screen
Using array index keys on lists that change
Heavy computed properties hiding expensive work
Putting local UI state into a global store
Eagerly loading every route, modal, chart, and editor
Shipping your entire database in SSR state
Leaving watchers, sockets, timers, and observers alive
Quick comparison
The order I would fix these
1. Rendering every row when the user only sees twenty
Scenario:
Your admin table is smooth with 200 rows. Then a customer imports 50,000 records, applies a filter, and the page freezes for several seconds.
Why it matters:
Vue cannot make 50,000 DOM nodes cheap. The browser still has to create them, style them, lay them out, and keep them in memory. If the user can only see twenty rows at a time, rendering all of them is wasted work.
Solution:
Use windowing, also called virtualization. Only render the rows currently visible, plus a small overscan buffer.
A simplified fixed-height windowed list looks like this:
<script setup lang="ts">
import { computed, ref } from "vue";
interface Row {
id: string;
label: string;
}
const props = defineProps<{
items: Row[];
itemHeight: number;
containerHeight: number;
}>();
const scrollTop = ref(0);
const overscan = 5;
const startIndex = computed(() => {
return Math.max(
0,
Math.floor(scrollTop.value / props.itemHeight) - overscan
);
});
const endIndex = computed(() => {
const visibleCount = Math.ceil(props.containerHeight / props.itemHeight);
return Math.min(props.items.length, startIndex.value + visibleCount + overscan);
});
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value).map((item, i) => ({
item,
index: startIndex.value + i,
}));
});
const totalHeight = computed(() => props.items.length * props.itemHeight);
const offsetY = computed(() => startIndex.value * props.itemHeight);
function onScroll(event: Event) {
const target = event.target as HTMLElement;
scrollTop.value = target.scrollTop;
}
</script>
<template>
<div
:style="{ height: `${containerHeight}px`, overflowY: 'auto' }"
@scroll="onScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="{ item, index } in visibleItems"
:key="item.id"
:style="{ height: `${itemHeight}px` }"
>
<slot :item="item" :index="index" />
</div>
</div>
</div>
</div>
</template>
Usage:
<script setup lang="ts">
import { ref } from "vue";
import FixedWindowList from "./FixedWindowList.vue";
const rows = ref([
{ id: "row_1", label: "First row" },
{ id: "row_2", label: "Second row" },
]);
</script>
<template>
<FixedWindowList
:items="rows"
:item-height="44"
:container-height="600"
>
<template #default="{ item }">
<div class="table-row">
{{ item.label }}
</div>
</template>
</FixedWindowList>
</template>
In production, I would usually reach for a maintained virtualization library instead of writing all the edge cases myself, especially for variable-height rows, sticky headers, keyboard navigation, or accessibility. But the architectural point is the same: render only what the user can see.
Why this works:
The DOM node count stops growing with the total dataset size. A list of 100,000 items can feel similar to a list of 100 items if only a small window is mounted.
⚠️ Gotcha: Virtualization becomes harder when rows have dynamic heights. If you need variable-height virtualization, test it with realistic content early.
2. Making giant API payloads deeply reactive
Scenario:
You fetch a large dataset from the API and store it in a reactive object. The page works, but scrolling, filtering, and switching tabs feel sluggish.
Why it matters:
Vue’s deep reactivity is powerful, but it has a cost. When you make a huge object deeply reactive, Vue needs to walk and instrument a large amount of data. Later, reading and writing nested properties goes through proxy machinery.
For large read-heavy datasets, that overhead is often unnecessary.
Solution:
Use shallowRef for large collections. Replace the whole value when the collection changes.
<script setup lang="ts">
import { shallowRef } from "vue";
interface Order {
id: string;
status: string;
total: number;
}
const orders = shallowRef<Order[]>([]);
async function loadOrders() {
const response = await fetch("/api/orders");
const data: Order[] = await response.json();
orders.value = data;
}
function updateOrder(id: string, patch: Partial<Order>) {
orders.value = orders.value.map((order) => {
return order.id === id ? { ...order, ...patch } : order;
});
}
</script>
<template>
<ul>
<li v-for="order in orders" :key="order.id">
{{ order.id }} — {{ order.status }}
</li>
</ul>
</template>
Why this works:
shallowRef makes only the top-level .value reactive. Vue does not deeply proxy the entire array and every nested object by default.
This is especially useful for:
Large tables
Chart data
Analytics results
Search indexes
Log viewers
Read-only report data
If you need to mutate a row, replace the collection immutably or use a targeted update strategy.
💡 Practical note: If you pass large reactive objects into a charting library or heavy non-Vue code, consider toRaw() to avoid interacting with proxies unnecessarily.
3. Deep watchers that watch the entire world
Scenario:
You add a watcher to synchronize a filter object with the URL or backend. It works. Then the filter object grows to include nested arrays, date ranges, saved views, and feature flags. Now every small change triggers expensive logic.
Why it matters:
A watcher like this is dangerous:
watch(filters, syncFilters, { deep: true });
It says: “Watch everything inside this object, no matter how large it becomes.”
At small scale, that is convenient. At large scale, it can cause repeated traversals, unexpected dependencies, and work that fires far more often than you intended.
Solution:
Watch the narrowest possible sources.
import { watch } from "vue";
watch(
() => filters.status,
(status) => {
applyStatusFilter(status);
}
);
watch(
() => [filters.owner, filters.teamId] as const,
([owner, teamId]) => {
applyOwnershipFilter(owner, teamId);
}
);
If you need to run an effect after DOM updates, be explicit:
watch(
() => visibleRows.value,
() => {
measureContainer();
},
{ flush: "post" }
);
Why this works:
Explicit dependencies make the cost predictable. You know exactly what triggers the watcher and why.
watchEffect can be convenient, but it automatically tracks every reactive dependency used inside it. In a large component, that can become surprisingly broad. For performance-sensitive code, explicit watch is often easier to control.
🔍 Why this matters: Deep watchers do not usually fail loudly. They just make the app feel heavier over time.
4. One giant component that re-renders the whole screen
Scenario:
You have a dashboard page component with 1,200 lines of template, several tabs, multiple tables, filter state, modals, and a few inline widgets. When one small piece of state changes, the whole page feels slow.
Why it matters:
Vue’s rendering is efficient, but it is not magic. If a giant component owns too much state, many parts of its template may need to re-evaluate when unrelated state changes.
The fix is not always “make more components.” The fix is to create boundaries around state and rendering responsibility.
Solution:
Split expensive or frequently changing regions into child components.
Before:
<script setup lang="ts">
import { ref } from "vue";
const rows = ref([]);
const selectedRowId = ref<string | null>(null);
const filterText = ref("");
const sidebarOpen = ref(false);
const activeTab = ref("overview");
</script>
<template>
<div>
<!-- Hundreds of lines of template -->
</div>
</template>
After:
<script setup lang="ts">
import { ref } from "vue";
import OrderTable from "./OrderTable.vue";
import OrderFilters from "./OrderFilters.vue";
const rows = ref([]);
const filterText = ref("");
</script>
<template>
<div>
<OrderFilters v-model="filterText" />
<OrderTable :rows="rows" />
</div>
</template>
The table component can own its own selection state:
<script setup lang="ts">
import { ref } from "vue";
interface Order {
id: string;
status: string;
}
defineProps<{
rows: Order[];
}>();
const selectedRowId = ref<string | null>(null);
</script>
<template>
<table>
<tbody>
<tr
v-for="row in rows"
:key="row.id"
:class="{ selected: row.id === selectedRowId }"
@click="selectedRowId = row.id"
>
<td>{{ row.id }}</td>
<td>{{ row.status }}</td>
</tr>
</tbody>
</table>
</template>
For very large list items, v-memo can also help when only a small part of the item affects expensive rendering:
<div
v-for="row in rows"
:key="row.id"
v-memo="[row.id === selectedRowId]"
>
<!-- Expensive row content -->
</div>
Why this works:
Child components create natural invalidation boundaries. If selection state lives inside the table, the filter bar does not need to re-evaluate when selection changes.
🧠 The important part: Component splitting is not just for code organization. It is a performance boundary strategy.
5. Using array index keys on lists that change
Scenario:
Your list renders fine. Then users start sorting, filtering, inserting, deleting, or reordering items. Suddenly inputs lose focus, checkboxes appear on the wrong rows, and row transitions look strange.
Why it matters:
Keys tell Vue how to match old virtual DOM nodes with new ones. If you use the array index as the key, Vue may reuse the wrong DOM elements when the list order changes.
This is a common anti-pattern:
<li v-for="(item, index) in items" :key="index">
{{ item.name }}
</li>
For static lists, this may be harmless. For dynamic lists, it can cause unnecessary DOM churn and incorrect component state reuse.
Solution:
Use stable identifiers.
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
If your data does not have a stable ID, create one when the data enters the frontend.
let localId = 0;
function withLocalId<T extends object>(item: T) {
return {
...item,
__localId: Symbol.for("local_id") in item ? item : `local_${++localId}`,
};
}
In practice, it is better to have a real ID from the backend if possible. Synthetic IDs should be stable for the lifetime of the item in the current list.
Why this works:
Vue can move, insert, and remove nodes instead of tearing down and rebuilding the wrong components. This matters a lot for lists containing inputs, async components, transitions, or expensive child components.
6. Heavy computed properties hiding expensive work
Scenario:
You have a search box, a table, and a computed property that filters, sorts, groups, and formats ten thousand records. It feels fine until the user types quickly.
Why it matters:
Computed properties are cached, but they re-evaluate when their dependencies change. If the dependency changes on every keystroke, the expensive computation runs on every keystroke.
This is a common shape:
const results = computed(() => {
return rows.value
.filter(matchesSearch)
.sort(compareByDate)
.map(formatRow);
});
If searchText changes constantly, that work happens constantly.
Solution:
Debounce the input, precompute expensive structures, or move heavy work off the main thread.
A simple debounce pattern:
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from "vue";
const searchText = ref("");
const debouncedSearchText = ref("");
let timer: number | undefined;
watch(searchText, (value) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
debouncedSearchText.value = value;
}, 200);
});
onUnmounted(() => {
window.clearTimeout(timer);
});
const results = computed(() => {
return rows.value.filter((row) =>
row.label.includes(debouncedSearchText.value)
);
});
</script>
<template>
<input v-model="searchText" type="search" />
</template>
For heavier work, consider:
Building a search index once after data loads
Sorting on the server
Paginating results
Using a Web Worker
Caching computed groups by stable filter state
Why this works:
You reduce how often expensive work runs and make the interaction cost predictable.
💡 Practical note: Do not assume “computed means fast.” Computed means cached until dependencies change. If dependencies change rapidly, the work still happens.
7. Putting local UI state into a global store
Scenario:
A dropdown, modal, hover state, table sort order, and sidebar state all live in a Pinia store. The app works, but unrelated components start updating when temporary UI state changes.
Why it matters:
Global state is useful when state is genuinely shared. But when everything becomes global, every component that reads from the store may become part of a broad invalidation graph.
A modal being open is usually not application state. It is component state.
A table’s current sort column may be local unless multiple components need to react to it.
A hover state almost never belongs in a global store.
Solution:
Keep state as local as possible. Lift it only when multiple components truly need it.
Instead of this:
export const useUiStore = defineStore("ui", () => {
const sidebarOpen = ref(false);
const activeModal = ref<null | "settings" | "export">(null);
const tableSort = ref("created_at");
const hoveredRowId = ref<string | null>(null);
return {
sidebarOpen,
activeModal,
tableSort,
hoveredRowId,
};
});
Prefer local state where appropriate:
<script setup lang="ts">
import { ref } from "vue";
const sidebarOpen = ref(false);
const activeModal = ref<null | "settings" | "export">(null);
</script>
Use the store for things that are genuinely shared:
export const useSessionStore = defineStore("session", () => {
const currentUser = ref<User | null>(null);
const workspace = ref<Workspace | null>(null);
return {
currentUser,
workspace,
};
});
Why this works:
Local state changes stay local. The rest of the app does not pay for them.
This also improves maintainability. When a component is deleted, its local state disappears with it. Global state tends to linger.
8. Eagerly loading every route, modal, chart, and editor
Scenario:
The initial page loads a charting library, a rich text editor, an admin settings panel, and five modal components that may never open. The app works, but the first load is heavy.
Why it matters:
At scale, bundle size becomes a performance feature. Users should not download the invoice editor if they are only viewing a dashboard.
Solution:
Split routes, lazy-load heavy components, and defer non-critical UI.
Route-level code splitting:
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
component: () => import("./pages/DashboardPage.vue"),
},
{
path: "/reports",
component: () => import("./pages/ReportsPage.vue"),
},
{
path: "/settings",
component: () => import("./pages/SettingsPage.vue"),
},
],
});
export default router;
Async component loading:
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
const InvoiceEditor = defineAsyncComponent(
() => import("./components/InvoiceEditor.vue")
);
</script>
<template>
<Suspense>
<InvoiceEditor v-if="showEditor" />
</Suspense>
</template>
For images:
<img
src="/charts/preview.png"
alt="Revenue preview"
loading="lazy"
decoding="async"
/>
Why this works:
The browser downloads less JavaScript upfront. Heavy components are loaded only when they are likely to be needed.
Be careful with over-splitting, though. Lazy-loading every tiny component can add request overhead and make navigation feel worse. Split at meaningful boundaries: routes, modals, tabs, editors, charts, admin panels, and heavy third-party integrations.
9. Shipping your entire database in SSR state
Scenario:
Your SSR page renders quickly on the server, but the browser receives a huge serialized state object. Hydration stalls, the page becomes interactive late, and memory usage spikes.
Why it matters:
Server-side rendering is not only about HTML. The client often needs to hydrate the app with some initial state. If that state contains every record, every relationship, and every internal flag, the client pays for all of it.
The problem often looks like this:
const initialState = {
orders: allOrders,
customers: allCustomers,
auditLogs: allAuditLogs,
featureFlags: everyFeatureFlag,
};
That may make server rendering easy, but it moves the cost to the browser.
Solution:
Send only the state needed for the current view.
For example:
const initialState = {
currentUser: {
id: user.id,
name: user.name,
workspaceId: user.workspaceId,
},
ordersPage: {
page: 1,
pageSize: 50,
rows: firstPageOrders,
total: orderCount,
},
};
Then fetch additional pages on the client.
If you are using a meta-framework, be careful with data helpers that return huge payloads into shared state. Ask:
Does the client need this immediately?
Can this be paginated?
Can this be fetched after hydration?
Can this remain server-only?
Does this state contain sensitive data?
Why this works:
Hydration becomes cheaper because the browser has less JSON to parse and less state to initialize.
SSR performance is not just server response time. It is also the cost of becoming interactive on the client.
10. Leaving watchers, sockets, timers, and observers alive
Scenario:
A dashboard page starts a polling timer, subscribes to a WebSocket, and adds a resize listener. The user navigates away. Later, the app feels slower, and the browser shows detached DOM nodes or repeated network calls.
Why it matters:
Vue components are not automatically responsible for every side effect you create inside them. If you start a timer, open a socket, add a listener, or observe an element, you usually need to clean it up.
This is especially visible in single-page apps where components mount and unmount repeatedly.
Solution:
Clean up in lifecycle hooks or watcher cleanup functions.
Using lifecycle hooks:
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
const controller = ref<AbortController | null>(null);
function onResize() {
// Recalculate layout
}
onMounted(() => {
controller.value = new AbortController();
window.addEventListener("resize", onResize, {
signal: controller.value.signal,
});
});
onBeforeUnmount(() => {
controller.value?.abort();
});
</script>
Using watcher cleanup:
import { watch } from "vue";
watch(
() => userId.value,
(id, _, onCleanup) => {
const socket = new WebSocket(`/ws/users/${id}`);
socket.addEventListener("message", handleMessage);
onCleanup(() => {
socket.removeEventListener("message", handleMessage);
socket.close();
});
},
{ immediate: true }
);
Why this works:
The cleanup runs before the next watcher execution or when the component unmounts, depending on the source. That prevents duplicate subscriptions and stale handlers.
This matters for:
WebSockets
Server-Sent Events
setInterval
setTimeout
ResizeObserver
IntersectionObserver
AbortController
Event bus subscriptions
Third-party widget instances
🚨 Production warning: HMR can hide leaks during development. A component may be re-created repeatedly without you noticing the accumulated listeners until production.
Quick comparison
Mistake
Typical symptom
Primary fix
Rendering all rows
Freezes with large tables
Virtualize or paginate
Deep reactive API payloads
Slow data handling
Use shallowRef or immutable updates
Deep watchers
Excessive background work
Watch narrow sources
Giant components
Broad re-rendering
Split state and rendering boundaries
Index keys
Weird list reuse bugs
Use stable IDs
Heavy computed properties
Typing or filtering lag
Debounce, precompute, paginate, workers
Global UI state
Unrelated updates
Keep temporary state local
Eager loading
Large initial bundle
Code split routes and heavy components
SSR state bloat
Slow hydration
Send only view-critical state
Missing cleanup
Memory leaks, duplicate calls
Clean up listeners and subscriptions
The order I would fix these
If I inherited a large Vue application with performance complaints, I would not start by randomly optimizing components.
I would start with the user-visible bottleneck.
If the page freezes while rendering data
Fix list rendering first.
Add virtualization for long lists.
Check list keys.
Split large row components.
Move expensive formatting out of the template.
If interactions feel delayed
Fix watchers and computed properties next.
Replace deep watchers with targeted watchers.
Debounce fast-changing inputs.
Move expensive sorting/filtering off the hot path.
Check whether computed properties depend on too much state.
If the initial load is heavy
Fix loading strategy.
Split routes.
Lazy-load modals, charts, editors, and admin screens.
Audit third-party dependencies.
Reduce SSR state size.
If the app gets slower over time
Fix leaks and state boundaries.
Clean up timers, sockets, and observers.
Move local UI state out of global stores.
Reduce component size.
Check for detached DOM nodes in browser tooling.
The broader principle is that Vue performance at scale is rarely about one magic API. It is about keeping the reactive graph small, the DOM bounded, the bundle split, and the side effects under control.
Vue gives you excellent tools for all of those. The mistake is assuming they will happen automatically.
Site: ass.auction (SFW, the ass is a pair of shorts) It's a joke site with a real payment flow, built during the outbid.lol wave. Brands pay to be on a leaderboard, the top 22 get rendered onto the boxers, and anyone can pay more to push you down. A few things I'd share with this sub: Rank is never
Open a Flutter web build in a browser, hit View Source, and the <body> you get back is a loading <div> and a <script src="flutter_bootstrap.js">. That is the whole page. Every heading, paragraph and link a human reads is pixels that CanvasKit painted into a <canvas> after a c
Give a coding agent a failing test and it fixes itself. Give it a stack trace, a type error, a red CI log, and it reads the signal, reasons about the cause, and corrects. That loop is the whole reason agents feel autonomous on the backend.
On the UI it falls apart.
The agent edits some CSS, sees n