Measuring and Improving Web Vitals in Vue Apps
AI generated
<v/>
{ }
Vue.js · Web Vitals · LCP · INP · CLS
Measuring Web Vitals in Vue Apps
Capturing LCP, INP and CLS correctly instead of guessing

Measuring Web Vitals in a single page application is not trivial, because classic metrics were designed for server rendered multi page websites. With the web-vitals library, correct timing and true real user monitoring, LCP, INP and CLS can be reliably captured in Vue apps as well.

19 min read web-vitals · LCP · INP · CLS · RUM Vue 3 · Nuxt 3

1. Why Web Vitals are a distinct challenge in Vue apps

Web Vitals are the metrics defined by Google to assess the perceived user experience of a website: Largest Contentful Paint for load speed, Interaction to Next Paint for interactivity, and Cumulative Layout Shift for visual stability. These metrics were originally designed for classic, server rendered multi page websites, where every navigation means a full page rebuild. A Vue single page application behaves fundamentally differently: after the initial load, the page persists, content is swapped client side, and the browser no longer fires a new navigation event.

This gap leads to a common misunderstanding: many teams measure Web Vitals only once at the initial load of the Vue app and ignore that users may subsequently navigate ten or twenty times within the application without a new page load occurring. Standard Web Vitals do not automatically cover these so called soft navigations, which means a performance problem on the fifth internal view stays completely invisible if you only rely on the initial measurement.

Measuring Web Vitals in Vue apps therefore needs extra care: you must understand which metric relates to which event, how to capture them correctly with the official web-vitals library, and how to send the data to a real user monitoring system that also reflects client side navigations within the Vue app.

2. Understanding and measuring LCP in Vue apps

Largest Contentful Paint measures the moment the largest visible element in the viewport is rendered, typically a hero image or a large heading. In a Vue app, this value is measured from the initial load to the first complete render. It becomes a problem when the LCP candidate itself is loaded asynchronously, such as a product image only rendered after an API call. In this case, LCP is delayed by the entire duration of the API call plus render time, which quickly leads to a poor LCP value with slow backend responses, even if the JavaScript bundle itself is small and fast.

A common improvement pattern: load critical LCP candidate data already during the initial server response or through an early, parallel API call, instead of requesting it only after Vue component initialization. For Nuxt apps, this means calling useAsyncData for LCP relevant content as early as possible in the render tree, instead of loading it in a deeply nested child component only reached after several render cycles.


// Bad: LCP candidate image waits for a client-side fetch after mount
import { ref, onMounted } from 'vue'

const heroImageUrl = ref(null)
onMounted(async () => {
  const res = await fetch('/api/hero-image')
  heroImageUrl.value = (await res.json()).url
  // LCP is delayed by network round-trip + render
})

3. INP: interaction latency in Vue apps

Interaction to Next Paint replaced First Input Delay as the interactivity metric and measures not just the first interaction, but the latency of all interactions across the entire page visit, where (with a few exceptions) the worst value is considered representative. This matters especially for Vue apps, since a single page application naturally has many interactions over a long dwell time, unlike a classic website with short single page visits.

A typical INP degrader in Vue apps: large, synchronously executed watchers or computed properties that trigger an expensive recalculation on every user input, such as an unfiltered sort of a large list on every keystroke in a search field. The main thread blocks during this time, and the browser cannot render the next frame until the computation completes. Debouncing input and moving expensive computations into requestIdleCallback or web workers are the common countermeasures for better INP values.


import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'

const searchQuery = ref('')
const filteredResults = ref([])

// Debounced expensive filtering keeps the main thread responsive,
// improving INP by avoiding a heavy synchronous computation per keystroke
const runFilter = useDebounceFn((query) => {
  filteredResults.value = allItems.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  )
}, 150)

watch(searchQuery, (newQuery) => runFilter(newQuery))

4. CLS: avoiding layout shifts from async rendering

Cumulative Layout Shift measures unexpected shifts of visible elements. In Vue apps, layout shifts frequently arise from asynchronously loaded content that has no reserved space in the layout: a banner appearing after an API call that pushes content below it downward, or a list whose height changes once actual data renders instead of a placeholder. For a good Web Vitals rating, every asynchronously loaded area must already reserve a fixed or at least estimated space before loading.

Skeleton components with a fixed height matching the expected height of the final content are the standard pattern against CLS in Vue apps. Equally important: images without explicit width and height attributes or without aspect-ratio in CSS cause layout shifts once they actually load, because the browser cannot reserve space for them beforehand. This problem affects server rendered content just as much as client side loaded content, but is especially present in Vue apps due to frequent asynchronous data loading.

5. Integrating the web-vitals library

Google maintains the official web-vitals JavaScript library, which uses the same logic that Chrome uses internally and that feeds the Chrome User Experience Report. For Vue apps, integration is straightforward: you import the needed functions and register callbacks called whenever a metric finalizes. It is important to include this integration as early as possible in the application lifecycle, ideally before the Vue app is even initialized, so no early events are missed.

The library distinguishes between the final value of a metric and intermediate values. For Web Vitals capture in Vue apps, the final value is usually enough, controlled through the reportAllChanges parameter. For debugging purposes during development, logging all intermediate values is worthwhile to understand how a metric evolves over time before it finalizes.


// main.js — register web-vitals before mounting the Vue app
import { onLCP, onINP, onCLS } from 'web-vitals'
import { createApp } from 'vue'
import App from './App.vue'

function sendToAnalytics(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    rating: metric.rating // 'good' | 'needs-improvement' | 'poor'
  })
  navigator.sendBeacon('/api/web-vitals', body)
}

onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)

createApp(App).mount('#app')

6. Sending Web Vitals to real user monitoring

Synthetic measurements from Lighthouse or PageSpeed Insights only show a simulated environment with fixed network and CPU throttling. For reliable Web Vitals data from real user sessions, you need real user monitoring that collects actual values across a large, diverse user base, including different devices, network conditions and geographic locations. navigator.sendBeacon is the preferred transmission method because it guarantees delivery even when the user is already leaving the page, which a classic fetch call would not ensure.

For Vue apps with many internal soft navigations, extending the standard integration pays off: on every Vue Router navigation change, a new, separate Web Vitals measurement is started for that specific view, instead of relying solely on the initial measurement. This surfaces performance problems that only occur on certain internal views, such as a data heavy dashboard page only reached after several clicks.


// router/index.js — track Web Vitals per soft navigation
import { onINP, onCLS } from 'web-vitals'

router.afterEach((to) => {
  // Reset per-view tracking so INP/CLS reflect this specific view,
  // not the entire session since the initial page load
  onINP((metric) => sendToAnalytics({ ...metric, route: to.path }), { reportAllChanges: false })
  onCLS((metric) => sendToAnalytics({ ...metric, route: to.path }))
})

7. Web Vitals in Nuxt: distinguishing SSR and client

In Nuxt applications with server side rendering, an additional distinction must be made between the server rendered HTML and client side hydration. LCP is usually dominated by the server rendered content, provided it is actually visible immediately, while INP measures almost exclusively client side interactivity after hydration. A common problem in Nuxt apps: content is already visible server side, but interactions only work once hydration completes, resulting in a state that is visible but not yet interactive.

For Web Vitals assessment in Nuxt, it is important to run the web-vitals integration client side only, since the library depends on browser APIs like PerformanceObserver that do not exist server side. A Nuxt plugin with a .client.js suffix ensures the integration runs exclusively in the browser, while the server side rendering process remains untouched.

8. Common measurement mistakes in single page applications

The most common mistake is capturing Web Vitals only once at the initial load and completely ignoring internal navigations. Since the standard Web Vitals API builds on the browser's navigation timing, which is not retriggered on soft navigations, performance problems on deeper views stay invisible unless you manually close this gap. A second mistake: Web Vitals values from the local development environment with unrealistically fast network and powerful hardware are treated as representative of real users, even though the actual user base often uses noticeably slower devices and connections.

A third mistake concerns the interpretation of aggregate values: the average of all INP values often masks a bimodal pattern, where most users have a good experience but a small group with old devices or slow networks produces massively worse values. Percentiles like the 75th percentile, which is also officially used for Web Vitals assessment, give a more realistic picture than a simple mean.

9. Web Vitals metrics compared

The following overview ranks the three core Web Vitals by what they measure and the typical causes of poor values in Vue apps.

Metric Measures Good value Typical cause in Vue apps
LCP Load time of the largest element Under 2.5 seconds LCP candidate waits on a client fetch
INP Interaction latency over the session Under 200 milliseconds Expensive watchers, undebounced input
CLS Unexpected layout shifts Under 0.1 No reserved space for async content

All three metrics are connected: an optimization that improves LCP, such as loading data early, can worsen CLS if the early rendered placeholder has a different height than the final content. Web Vitals in Vue apps should therefore always be viewed as a whole, not as isolated individual metrics optimized independently of each other.

Mironsoft

Web Vitals monitoring and performance optimization for Vue apps

Poor Web Vitals despite a fast development environment?

We integrate true real user monitoring into your Vue app, capture LCP, INP and CLS on internal navigations too, and identify the concrete causes of poor values.

RUM integration

web-vitals library and connection to your analytics system

Soft navigation tracking

Web Vitals per internal view instead of only at initial load

Concrete optimization

Targeted improvement of LCP, INP and CLS instead of blanket guessing

10. Summary

Measuring Web Vitals in Vue apps needs more care than in classic multi page websites, because a single page application persists after the initial load and internal navigations are not automatically recognized as new measurement points. The official web-vitals library delivers correct LCP, INP and CLS values, but must be deliberately tied to Vue Router navigations to surface problems on deeper views as well.

Real user monitoring with navigator.sendBeacon provides more reliable data than synthetic Lighthouse measurements, since it reflects the actual, diverse user base. In Nuxt apps with server side rendering, the integration must additionally distinguish cleanly between server side rendering and client side hydration. Treating Web Vitals in Vue apps as a whole rather than isolated individual metrics also reveals trade offs between the metrics early.

Web Vitals in Vue Apps, the essentials at a glance

LCP

Load critical data early, do not let LCP candidates wait on client side fetches.

INP

Debounce expensive watchers and computed properties, do not block the main thread on input.

CLS

Reserve fixed space for asynchronously loaded content, always set dimensions on images.

Measurement

web-vitals library with sendBeacon and tracking per soft navigation instead of only at initial load.

11. FAQ: Web Vitals in Vue Apps

1Why are Web Vitals different in Vue apps?
SPAs persist after loading, internal navigations do not trigger a new browser event.
2Is Lighthouse enough?
No, only synthetic lab values. Use real user monitoring for actual user experience.
3Most common cause of poor LCP?
The LCP candidate waits on a client side API call instead of being available early.
4Improve INP on live filtering?
Debounce the filtering, consider web workers for very large datasets.
5Why do skeletons cause CLS?
If the height does not match the final content, the layout shifts during the swap.
6When to include web-vitals?
As early as possible, ideally before createApp().mount().
7Why sendBeacon over fetch?
sendBeacon guarantees transmission even when the user is leaving the page.
8Does web-vitals work server side?
No, it needs browser APIs. In Nuxt it belongs in a client only plugin.
9Average or percentile?
The 75th percentile, as also used for the official assessment.
10Do the metrics influence each other?
Yes, for example loading early for LCP can cause a layout shift with a mismatched placeholder height.