DEV Community

Cover image for 10 Vue Performance Mistakes That Only Show Up at Scale
Hossein Hezami
Hossein Hezami

Posted on

10 Vue Performance Mistakes That Only Show Up at Scale

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

  1. Rendering every row when the user only sees twenty
  2. Making giant API payloads deeply reactive
  3. Deep watchers that watch the entire world
  4. One giant component that re-renders the whole screen
  5. Using array index keys on lists that change
  6. Heavy computed properties hiding expensive work
  7. Putting local UI state into a global store
  8. Eagerly loading every route, modal, chart, and editor
  9. Shipping your entire database in SSR state
  10. Leaving watchers, sockets, timers, and observers alive
  11. Quick comparison
  12. 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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);
  }
);
Enter fullscreen mode Exit fullscreen mode

If you need to run an effect after DOM updates, be explicit:

watch(
  () => visibleRows.value,
  () => {
    measureContainer();
  },
  { flush: "post" }
);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}`,
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
  };
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
  };
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

For images:

<img
  src="/charts/preview.png"
  alt="Revenue preview"
  loading="lazy"
  decoding="async"
/>
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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,
  },
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)