DEV Community

Cover image for The Vue 3 Reactivity Trap: Why Large Datasets Crash Your Browser
Ameer Hamza
Ameer Hamza

Posted on

The Vue 3 Reactivity Trap: Why Large Datasets Crash Your Browser

If you’ve ever loaded a table with 10,000 rows in Vue 3 and watched the browser tab freeze, memory usage spike, and the CPU fan spin up like a jet engine, you’ve fallen into the reactivity trap.

Vue 3’s reactivity system, powered by ES6 Proxies, is incredibly elegant. It "just works" for 95% of use cases. But when you start dealing with large datasets—think financial dashboards, log viewers, or complex data grids—that elegance becomes a massive performance bottleneck.

In this deep dive, we’ll explore exactly why Vue’s default reactivity chokes on large arrays, how to measure the impact, and the production-ready patterns to fix it.

The Naive Approach: Deep Reactivity by Default

Let's look at a common scenario. You fetch a large array of objects from an API and store it in a ref.

<script setup>
import { ref, onMounted } from 'vue'

const logs = ref([])

onMounted(async () => {
  const response = await fetch('/api/system-logs')
  // Assume this returns 50,000 log objects
  logs.value = await response.json() 
})
</script>

<template>
  <div v-for="log in logs" :key="log.id">
    {{ log.timestamp }} - {{ log.message }}
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Why This Breaks in Production

When you assign that 50,000-item array to logs.value, Vue doesn't just store the array. It recursively walks through every single object and every single nested property, wrapping them in ES6 Proxies to track changes.

If each log object has 10 properties, Vue just created 500,000 Proxies.

This deep conversion takes significant synchronous CPU time, blocking the main thread. The browser freezes. Furthermore, each Proxy consumes memory. A 5MB JSON payload can easily balloon into 50MB of reactive overhead in RAM.

The Fix: Opting Out of Deep Reactivity

If you are rendering a massive list, ask yourself: Do I actually need to mutate individual properties of these objects?

In most cases (like a log viewer or a data table), the answer is no. You might replace the entire list, or append to it, but you aren't doing logs.value[4021].message = 'new message'.

Pattern 1: shallowRef

The easiest and most effective fix is to use shallowRef instead of ref.

<script setup>
import { shallowRef, onMounted } from 'vue'

// Only the .value reassignment is tracked.
// The array elements themselves remain plain objects.
const logs = shallowRef([])

onMounted(async () => {
  const response = await fetch('/api/system-logs')
  logs.value = await response.json() 
})

// This WILL trigger a re-render:
const refreshLogs = (newLogs) => {
  logs.value = newLogs
}

// This WILL NOT trigger a re-render (and won't work reactively):
const mutateSingleLog = () => {
  logs.value[0].message = 'Updated' 
}
</script>
Enter fullscreen mode Exit fullscreen mode

By using shallowRef, Vue only tracks the .value property. It skips the recursive Proxy generation entirely. The performance difference is staggering—what took 800ms to process now takes 5ms.

Pattern 2: markRaw for Mixed State

Sometimes you have a reactive object (like a Pinia store or a complex component state) where most properties need deep reactivity, but one specific property holds a massive dataset.

You can't use shallowRef inside a reactive object. Instead, use markRaw.

<script setup>
import { reactive, markRaw } from 'vue'

const state = reactive({
  isLoading: false,
  filterQuery: '',
  // Tell Vue: "Never wrap this specific array in Proxies"
  largeDataset: markRaw([]) 
})

const loadData = async () => {
  state.isLoading = true
  const data = await fetchLargeData()
  // We must mark the new array as raw before assignment
  state.largeDataset = markRaw(data)
  state.isLoading = false
}
</script>
Enter fullscreen mode Exit fullscreen mode

markRaw adds a hidden __v_skip flag to the object, instructing Vue's reactivity system to ignore it.

The Next Bottleneck: DOM Rendering

Fixing the reactivity overhead solves the memory and CPU spike during data assignment. But if you try to render 50,000 <div> elements, the browser will still crash. The DOM is inherently slow.

The Solution: Virtualization (Windowing)

To handle massive lists, you must use virtualization. This technique only renders the DOM nodes that are currently visible in the viewport, plus a small buffer. As the user scrolls, the DOM nodes are recycled and updated with new data.

Instead of building this from scratch, use a proven library like @vueuse/core (specifically useVirtualList) or vue-virtual-scroller.

Here is how you combine shallowRef with VueUse's useVirtualList:

<script setup>
import { shallowRef } from 'vue'
import { useVirtualList } from '@vueuse/core'

// 1. Fast reactivity
const massiveList = shallowRef(Array.from({ length: 50000 }, (_, i) => ({
  id: i,
  text: `Item ${i}`
})))

// 2. Fast DOM rendering
const { list, containerProps, wrapperProps } = useVirtualList(
  massiveList,
  {
    itemHeight: 40, // Fixed height is most performant
    overscan: 10    // Render 10 items outside viewport for smooth scrolling
  }
)
</script>

<template>
  <!-- The scrollable container -->
  <div v-bind="containerProps" style="height: 400px; overflow-y: auto;">
    <!-- The wrapper that simulates the full height -->
    <div v-bind="wrapperProps">
      <!-- Only the visible items are rendered -->
      <div 
        v-for="item in list" 
        :key="item.data.id"
        style="height: 40px;"
      >
        {{ item.data.text }}
      </div>
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Conclusion

Vue 3's reactivity is powerful, but with great power comes the responsibility to know when to turn it off.

  1. Default to ref for primitives and small objects.
  2. Switch to shallowRef the moment you are dealing with large arrays or complex objects that don't require deep mutation tracking.
  3. Use markRaw when you need to embed non-reactive massive datasets inside a deeply reactive state object.
  4. Always virtualize the DOM when rendering lists longer than a few hundred items.

By combining shallowRef and virtualization, you can render lists of millions of items in Vue 3 without dropping a single frame.

What's your approach?

Have you run into reactivity bottlenecks in your Vue applications? Did you use shallowRef, or did you find another workaround? Let me know in the comments!


About the Author: Ameer Hamza is a Top-Rated Full-Stack Developer with 7+ years of experience building SaaS platforms, eCommerce solutions, and AI-powered applications. He specializes in Laravel, Vue.js, React, Next.js, and AI integrations — with 50+ projects shipped and a 100% job success rate. Check out his portfolio at ameer.pk to see his latest work, or reach out for your next development project.

Top comments (0)