Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 030 / 100

Vue performance tips: Vue & Nuxt lessons from production

Keep apps fast.

Expona Admin7 min read

We've all felt it—that moment when a Vue or Nuxt app starts to lag. Thousands of items render slowly, components re-render unnecessarily, and your users see janky scrolling. After running production systems handling real traffic, we've learned that Vue performance tips: Vue & Nuxt lessons from production aren't optional luxuries. They're the difference between a smooth, competitive product and one users abandon.

Performance isn't just about raw JavaScript speed. It's about understanding how Vue's reactivity system works, where rendering bottlenecks hide, and which optimization techniques actually move the needle. In this article, we'll walk through the core strategies that keep our teams' apps fast at scale.

Stable keys — Correct :key values in v-for

This is the foundation. If you get :key wrong, everything else becomes harder to optimize.

Vue uses keys to identify which list items have changed, been added, or been removed. Without stable keys—or worse, with index-based keys—Vue may reuse DOM nodes incorrectly. A component that was rendering item A suddenly renders item B, but its internal state (form inputs, toggles, scroll position) remains tied to the old item. Users see corrupted UI.

In production, we saw this in an e-commerce order list. Filters would change the order of items, but if we'd used the array index as the key, Vue would recycle nodes. A component showing "quantity: 5" for order ID 1001 would keep that count even when the node was reused for order 2045. Inventory sync broke.

The rule is simple: use a unique, stable identifier from your data, not the index.

typescript
// ❌ Wrong: index-based key
<div v-for="(item, index) in items" :key="index">
  <input v-model="item.quantity" />
</div>

// ✅ Correct: stable ID from data
<div v-for="item in items" :key="item.id">
  <input v-model="item.quantity" />
</div>

If your data doesn't have an ID, generate one consistently (e.g., a hash of the item's unique properties, or an incrementing counter added at fetch time). Never rely on the array position.

In Nuxt, this becomes critical when you're server-rendering lists and then hydrating them on the client. A mismatch in key stability between server and client will cause hydration errors and force a full re-render—exactly what we want to avoid.

v-memo and v-once — Skip re-rendering static parts

Not every part of your component needs to react to every state change. Vue 3 gives us two directives to skip unnecessary re-renders: v-memo and v-once.

v-once is the simpler one: it tells Vue "render this subtree once, never update it." It's useful for static content that never changes, like footer disclaimers or immutable header branding.

typescript
// This subtree is rendered once and never updated
<div v-once>
  <p>© 2025 Acme Corp. All rights reserved.</p>
  <img src="/logo.png" alt="Acme" />
</div>

v-memo is more powerful. It lets you specify dependencies—a list of values that, when they change, trigger a re-render of that subtree. If the dependencies haven't changed, Vue skips the render entirely.

We used this in a trading dashboard with hundreds of stock cards. Each card displayed price, percentage change, and a sparkline chart. The chart component was expensive—it re-rendered on every parent update. By adding v-memo with the stock ID and price as dependencies, we ensured the card only re-rendered when its own data changed, not when other stocks updated.

typescript
// Re-render only if stockId or price changes
<StockCard 
  v-memo="[stock.id, stock.price]"
  :stock="stock"
  @select="handleSelect"
/>

// Without v-memo, StockCard would re-render 
// every time the parent component updates

The key is identifying the minimal set of dependencies. Too few, and your UI becomes stale. Too many, and you lose the benefit. In our experience, this technique shaves 20–40% off render time in lists with hundreds of items, especially when paired with stable keys.

Lazy components — defineAsyncComponent for heavy UI

Some components are genuinely heavy: rich text editors, charting libraries, video players. Loading them eagerly on every page wastes bandwidth and delays initial page paint. Enter defineAsyncComponent.

Instead of bundling a component upfront, you can load it asynchronously when it's actually needed. In Nuxt, this is even easier with the <NuxtAsyncComponent> or the shorthand auto-import syntax.

typescript
// Vue 3 + Vite: automatic chunk splitting
import { defineAsyncComponent } from 'vue'

const HeavyEditor = defineAsyncComponent(() =>
  import('./components/RichEditor.vue')
)

// In Nuxt, even simpler:
const HeavyEditor = defineAsyncComponent(
  () => import('~/components/RichEditor.vue')
)

// Use it in your template like any other component
<HeavyEditor v-if="isEditing" @save="handleSave" />

The browser now loads the editor's code only when the user actually opens the editor. Your initial JavaScript bundle shrinks, pages load faster, and Time to Interactive improves.

We saw this in a SaaS CRM where reports pages had three optional chart libraries. Bundling all three upfront added 350 KB to the page. With lazy loading, users who never opened the advanced reports didn't download their code. Pages loaded in half the time for the majority of users.

Pair this with a loading fallback and error handling:

typescript
const HeavyEditor = defineAsyncComponent({
  loader: () => import('./components/RichEditor.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorFallback,
  delay: 200,
  timeout: 10000
})

Virtual lists — For thousands of rows

If you're rendering thousands of items—search results, chat messages, log entries—the DOM will choke. Virtual scrolling (or windowing) is non-negotiable.

The idea: only render the items visible in the viewport, plus a small buffer above and below. As the user scrolls, you swap items in and out. A list of 50,000 rows becomes a list of 30–50 active DOM nodes.

In Nuxt and Vue, the go-to library is vue-virtual-scroller. It handles the math—calculating which items to show based on scroll position—and leaves you to define the item template.

typescript
// Install: npm install vue-virtual-scroller
import { RecycleScroller } from 'vue-virtual-scroller'

<RecycleScroller
  class="scroller"
  :items="messages"
  :item-size="60"
  v-slot="{ item }"
>
  <div class="message">
    <strong>{{ item.author }}</strong>
    <p>{{ item.text }}</p>
  </div>
</RecycleScroller>

<style scoped>
.scroller {
  height: 600px;
  overflow-y: auto;
}
.message {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
</style>

We used this in a ride-hailing app's trip history. With 10,000+ trips per user over time, rendering them all was impossible. Virtual scrolling dropped render time from seconds to milliseconds. Scrolling felt instant.

One caveat: virtual scrolling works best when all items are roughly the same height. If heights vary significantly, the scroller needs to know each item's height upfront, which adds complexity. For most use cases, though, it's a game-changer.

Common mistakes

We've seen these patterns hurt performance more than once:

  • Computed properties that call methods: Computed properties are cached by their dependencies, but if you call a function inside, it runs on every access. Move the function call into the computed property body or memoize it separately.
  • Watchers on deeply nested objects: Watching an entire object tree (e.g., watch(() => state, handler, { deep: true })) triggers on any nested change. Be specific: watch only the property you care about.
  • Inline objects/arrays in templates: :items="{ a: 1 }" creates a new object on every render. Define it in your component data or a computed property instead.
  • Missing :key in v-for loops inside modals or conditionals: Modals that re-mount frequently can reuse stale nodes if keys are unstable. We saw this in a support chat where old conversations leaked into new ones.
  • Loading entire large datasets at once: Nuxt doesn't prevent you from fetching 100,000 records. Use pagination, lazy-loading, or server-side filtering. Only fetch what you need.

Key takeaways

  • Always use stable, data-derived keys in v-for loops. Index-based keys cause subtle bugs and regressions.
  • Use v-memo for expensive component subtrees and v-once for truly static content.
  • defineAsyncComponent reduces initial bundle size and speeds up page load when components aren't immediately needed.
  • Virtual scrolling is essential for lists with more than a few hundred items. Don't let the DOM become your bottleneck.
  • Profile your app with Vue DevTools and your browser's Performance tab. Measure before and after optimizations—intuition often misleads us.
  • In Nuxt, server-side rendering adds complexity to performance tuning. Test hydration carefully, especially around keys and async components.
  • Performance is a team habit, not a one-time fix. Document these patterns in your code review checklist and catch regressions early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles