Using shallowRef, markRaw and toRaw deliberately
Blindly wrapping every object in Vue 3 with reactive costs a measurable Proxy overhead once datasets grow large. This Vue reactivity performance guide shows how shallowRef, shallowReactive, markRaw and toRaw relieve the reactivity system deliberately, without losing functionality.
Table of Contents
- 1. Why reactivity overhead exists in the first place
- 2. The Proxy system and its cost in detail
- 3. Using shallowRef and shallowReactive deliberately
- 4. markRaw: excluding objects from reactivity permanently
- 5. toRaw and readonly: reading raw data safely
- 6. Actually measuring reactivity overhead
- 7. Common anti-patterns that create overhead
- 8. Interaction with Pinia and composables
- 9. Reactivity tools compared directly
- 10. Summary
- 11. FAQ
1. Why reactivity overhead exists in the first place
Vue 3 builds its reactivity system on JavaScript Proxies. Calling reactive(obj) does not return a plain object, it returns a Proxy that intercepts every read and write, registers dependencies and reruns affected effects when values change. For small, flat objects this mechanism is practically free. For deeply nested structures with thousands of entries, such as product catalogs, table rows or imported JSON responses, the Vue reactivity performance cost becomes noticeable, because Vue recursively wraps every nested object in its own Proxy as soon as it is accessed.
The problem rarely shows up immediately. A form with twenty fields feels no difference. A table with five thousand rows, whose complete records are loaded into a reactive array, produces noticeable delays on initial rendering and on every update. Vue reactivity performance therefore depends directly on data volume and nesting depth. This is exactly where the shallow variants of the reactivity system come in: shallowRef, shallowReactive, markRaw and toRaw give developers precise control over how deep Vue actually needs to make something reactive.
An important distinction: these tools are not a general replacement for ref and reactive. They are targeted optimizations for concrete bottlenecks that should only be applied after measuring. Using them preventively everywhere can remove desired reactivity and produce hard to find bugs, because changes to nested fields no longer trigger re renders.
2. The Proxy system and its cost in detail
Every Proxy in Vue 3 has handlers for get, set, has, deleteProperty and more traps. When reading a property, the get handler registers the currently running effect function as a dependency (dependency tracking). When writing, the set handler triggers all registered effects (trigger). These two steps cost CPU time that does not matter for a single object, but adds up across tens of thousands of object properties into a measurable amount that directly affects Vue reactivity performance.
The problem is amplified by Vue's lazy nested Proxy strategy: a nested object is only converted into a Proxy once it is actually accessed. That sounds like an optimization, but it means a single iteration over a deeply nested array can potentially create thousands of new Proxies, because every access to a nested object triggers Proxy creation. In a table whose rows each contain an object with metadata, new Proxy instances are created on every full pass whenever the array itself is reassigned.
Another cost factor: every Proxy access is an additional function call compared to a direct property access on a plain object. In hot paths, such as inside a render function iterating over thousands of rows while reading several nested fields per row, this overhead adds up to noticeable frame drops. The following sections show which concrete APIs let you avoid this overhead deliberately, without giving up reactivity where it is truly needed.
// Naive approach: deep reactive object with thousands of nested entries
import { reactive } from 'vue'
// Every nested product object becomes its own Proxy on first access
const catalog = reactive({
products: Array.from({ length: 8000 }, (_, i) => ({
id: i,
name: `Product ${i}`,
price: 19.99,
metadata: { supplier: 'Acme', warehouse: 'DE-01', tags: ['sale', 'new'] }
}))
})
// Iterating and reading nested metadata forces Vue to wrap
// every single metadata object in a new Proxy on first read
function totalTags() {
return catalog.products.reduce((sum, p) => sum + p.metadata.tags.length, 0)
}
3. Using shallowRef and shallowReactive deliberately
shallowRef only makes the top level .value assignment reactive, not nested properties inside the referenced object. Changing a field deep inside the object does not trigger a re render. Replacing the entire object with myRef.value = newObject works reactively as expected. This pattern fits data that is swapped as a whole, such as the result of an API call that gets fully replaced after each fetch instead of mutating individual fields.
shallowReactive behaves similarly but for objects instead of refs: only top level properties are reactive, nested objects remain untouched and do not trigger reactions on mutation. For Vue reactivity performance this matters especially for large lists whose individual entries rarely or never change, but whose count or order changes frequently. You then combine shallowReactive for the container with targeted triggerRef calls if a deep mutation is truly required.
A practical use case: a chart visualizing large time series datasets. The raw data only changes fully on reload, never through individual field changes. With shallowRef you avoid the entire recursive Proxy creation for thousands of data points and achieve the same functionality with noticeably less overhead.
import { shallowRef, shallowReactive, triggerRef } from 'vue'
// shallowRef: only the top-level .value assignment is reactive
const chartData = shallowRef([])
async function loadChartData() {
const response = await fetch('/api/timeseries')
// Full replacement triggers reactivity, no deep Proxy wrapping needed
chartData.value = await response.json()
}
// shallowReactive: top-level properties are reactive, nested objects are not
const tableState = shallowReactive({
rows: [], // reassigning tableState.rows triggers updates
page: 1,
sortBy: 'name'
})
function replaceRows(newRows) {
tableState.rows = newRows // reactive, triggers re-render
}
function mutateRowDeep(row) {
row.status = 'archived' // NOT reactive with shallowReactive container
triggerRef(tableState) // manual escape hatch if truly needed
}
4. markRaw: excluding objects from reactivity permanently
markRaw permanently marks an object as non convertible. Even if it is later stored inside a reactive container, it stays a plain JavaScript object without a Proxy wrapper. This is the right choice for objects that never need to be observed themselves, but end up in reactive state for practical reasons. Typical candidates are instances of third party libraries such as Chart.js, Leaflet maps, editor instances like CodeMirror, or large, immutable reference data such as icon sets or translation tables.
The performance benefit exists because Vue skips the entire recursive Proxy creation for objects marked with markRaw. For Vue reactivity performance this means a Chart.js instance object with hundreds of internal references does not get turned into thousands of nested Proxies just because it happens to be stored as a reference inside a reactive component. Without markRaw, Vue would try to wrap every internal property of the library in a Proxy, which can cause errors or unnecessary overhead with complex object graphs.
A second important aspect: markRaw also prevents subtle bugs. Some libraries expect their internal objects to be exactly the reference they created themselves, not a Proxy wrapper around it. Without markRaw, internal identity checks in the library can fail, because instanceof checks or reference comparisons behave differently against a Proxy than against the original.
import { reactive, markRaw } from 'vue'
import Chart from 'chart.js/auto'
const state = reactive({
// Without markRaw, Vue would try to deeply wrap the Chart instance
chartInstance: null,
editorInstance: null,
isLoading: false
})
function initChart(canvas) {
const instance = new Chart(canvas, { type: 'line', data: {} })
// markRaw prevents Vue from proxying internal Chart.js state
state.chartInstance = markRaw(instance)
}
// Also useful for large static reference data that never changes
import iconSet from './icon-set.json'
const staticIcons = markRaw(iconSet)
5. toRaw and readonly: reading raw data safely
toRaw returns the original, unwrapped object behind a reactive Proxy. This is useful when passing data to an external API that does not understand Proxies, or when creating a deep copy without the reactivity overhead cost, for example with structuredClone(toRaw(state)). Important: the object obtained through toRaw stays linked to the original, mutations on it bypass the trigger system but still change the underlying data, which can lead to inconsistent state if you are not careful.
For read operations that happen frequently inside loops, such as exporting a large table as CSV, toRaw is also worthwhile: you avoid every single access during the iteration going through the Proxy system, which makes a noticeable difference for Vue reactivity performance with tens of thousands of rows. readonly completes this picture from the other direction: it creates a Proxy that blocks write attempts with a warning, but keeps read access reactive, ideal for state passed to child components that should only display it, not change it.
A common mistake is using toRaw and then accidentally binding the result back into a template. Since the raw object is no longer a Proxy, changes to it no longer show up automatically in the template. toRaw therefore belongs exclusively in imperative, one off operations such as serialization, logging or export, never in reactive bindings.
import { reactive, toRaw, readonly } from 'vue'
const largeDataset = reactive({ rows: [/* thousands of entries */] })
function exportToCsv() {
// Bypass the Proxy system entirely for a read-heavy loop
const raw = toRaw(largeDataset)
return raw.rows.map(r => `${r.id},${r.name},${r.price}`).join('\n')
}
function deepCloneWithoutOverhead() {
// structuredClone on the raw object avoids proxying during the clone walk
return structuredClone(toRaw(largeDataset))
}
// readonly: children can read reactively but not mutate the source
const config = reactive({ theme: 'dark', locale: 'en-US' })
export function useReadonlyConfig() {
return readonly(config)
}
6. Actually measuring reactivity overhead
Before reaching for shallowRef, shallowReactive or markRaw, you should measure the actual overhead instead of guessing. The Chrome DevTools Performance tab shows in the flame graph view clearly how much time is spent inside Vue internal functions such as createGetter, track and trigger. If these function calls pile up in the profile, that is a strong signal of reactivity overhead that the shallow variants can reduce.
Programmatically, the difference can also be measured with the User Timing API: performance.mark before and after creating a reactive object, then performance.measure for the difference. In a test with an array of ten thousand nested objects, a factor of three to eight typically shows up between a fully deep reactive structure and a shallowReactive variant, depending on nesting depth and the number of accesses during the measurement.
Important for interpretation: the pure creation overhead is often smaller than the overhead from repeated access during rendering. That is why it is worth measuring across the full lifecycle: creation, first render, a typical update scenario. Only then can you tell whether the actual bottleneck lies in Proxy creation or in repeated get and set access during interaction.
import { reactive, shallowReactive } from 'vue'
function measureCreation(factory, label) {
performance.mark(`${label}-start`)
const result = factory()
performance.mark(`${label}-end`)
performance.measure(label, `${label}-start`, `${label}-end`)
const [entry] = performance.getEntriesByName(label)
console.log(`${label}: ${entry.duration.toFixed(2)}ms`)
return result
}
const rows = Array.from({ length: 10000 }, (_, i) => ({ id: i, meta: { tag: 'x' } }))
measureCreation(() => reactive({ rows: [...rows] }), 'deep-reactive')
measureCreation(() => shallowReactive({ rows: [...rows] }), 'shallow-reactive')
// Compare entry.duration values in the console to quantify the overhead
7. Common anti-patterns that create overhead
The most common anti-pattern is unnecessarily nesting reactive inside reactive, for example when a component wraps an already reactive object from a store with reactive() again. Vue detects this case and returns the existing Proxy, but the extra function call and check still cost time unnecessarily when it happens in thousands of places in the code. A second common pattern: large, immutable configuration objects are accidentally stored with reactive instead of a plain constant or markRaw, even though they are never mutated.
A third anti-pattern involves computed properties based on deeply nested reactive structures that trigger a full traversal on every access. Instead of building a single computed property that walks the entire nested object, it is often worth pre processing with shallowRef, so that only the actually needed, already aggregated values are kept reactive. This reduces not only the Vue reactivity performance cost, but also makes the data flow easier to follow.
A fourth, more subtle problem: array methods like push, splice or sort on large reactive lists trigger multiple trigger cycles on every call, because Vue instruments array methods internally. Adding many elements one by one with push inside a loop creates many individual reactivity triggers instead of a single update. Collecting all new elements in a separate array and doing a single list.value = [...list.value, ...newElements] call at the end drastically reduces the number of trigger cycles.
8. Interaction with Pinia and composables
Pinia stores are also built on reactive internally, so the same Vue reactivity performance considerations apply there as well. Large state trees in a store that are mutated rarely but read frequently benefit from being declared with shallowRef in the setup store syntax. In the options syntax of Pinia, the same behavior can be achieved through markRaw for individual state fields, for example for instances of third party clients held in the store, such as a WebSocket client or an API SDK object.
Composables that encapsulate large amounts of data, such as a useTableData composable managing a paginated server response, should store the raw dataset with shallowRef by default and only make the actually template relevant derived values reactive through computed. This pattern cleanly separates the transport container for raw data from the actual UI relevant reactivity, which improves both Vue reactivity performance and the testability of the composable, because raw data and derived state can be checked independently.
9. Reactivity tools compared directly
The choice between ref, reactive, the shallow variants and markRaw depends on the concrete access pattern, not a blanket rule. The following overview ranks the tools by reactivity depth and typical use case for better Vue reactivity performance.
| Tool | Reactivity depth | Overhead | Typical use case |
|---|---|---|---|
| reactive | Full, recursive | High for large objects | Small to medium forms and UI state |
| shallowReactive | Top level only | Low | Containers with rarely mutated nested entries |
| shallowRef | .value assignment only | Very low | Fully replaced API responses, time series |
| markRaw | None | None | Third party instances, static reference data |
| toRaw | None (raw access) | None | Export, serialization, deep clone |
As a rule of thumb: start with ref and reactive, since they are performant enough in most cases. Only once a measurement with the DevTools or the User Timing API shows a concrete bottleneck do you switch deliberately to a shallower variant for exactly the affected state. This incremental approach avoids premature optimization while keeping the code readable, because the shallow variants only appear where they actually make a difference for Vue reactivity performance.
Mironsoft
Vue 3 performance audits and reactivity optimization
Vue app with noticeable reactivity overhead?
We analyze your Vue 3 codebase with Chrome DevTools, identify expensive Proxy access patterns and replace them deliberately with shallowRef, shallowReactive and markRaw, without losing functionality.
Performance audit
Profiling with Chrome DevTools and the User Timing API for concrete bottlenecks
Refactoring
Deliberate use of shallow reactivity without losing functionality
Pinia store review
Checking state trees for unnecessary reactivity overhead
10. Summary
Vue reactivity performance depends directly on how deep and how often Vue has to access state through Proxies. reactive and ref are performant enough for most cases, but produce an overhead with large, deeply nested datasets that shows up clearly in flame graphs. shallowRef and shallowReactive limit reactivity to the top level and fit data that is swapped completely instead of mutated individually. markRaw permanently excludes objects from Proxy creation, ideal for third party instances and static reference data. toRaw provides raw access for export and serialization without Proxy overhead.
The most important principle remains: measure first, optimize second. The Chrome DevTools Performance tab and the User Timing API show concretely where Vue actually spends time inside track and trigger calls. Only with this data foundation can you decide whether and where shallowRef, shallowReactive or markRaw actually make a measurable difference for the Vue reactivity performance of your own application.
Vue Reactivity Performance, the essentials at a glance
Shallow reactivity
shallowRef and shallowReactive limit Proxy creation to the top level, ideal for fully replaced datasets.
Excluding objects
markRaw permanently prevents any Proxy creation, suitable for third party instances and static data.
Raw access
toRaw for export, serialization and deep clone, never for reactive template bindings.
Measure before optimizing
Chrome DevTools flame graph and User Timing API reveal concrete bottlenecks before you optimize.