Performance and Deployment

This section documents the current Vue 3.x release line as published at the official Vue.js documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. The Composition API with <script setup> is the authoring style used throughout; the Options API is shown only as a contrast.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vue and its ecosystem iterate quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Vue is fast by default, but a large app still benefits from measuring before optimising and from a build and deployment setup tuned for production. This page pairs the Performance and Production Deployment guides with worked deployment recipes.

Profiling

Measure first — the guide’s Profiling Tools section lists these:

  • Vue DevTools timeline — the Performance tab records component render durations and event timings in a development build.

  • Browser performance panel — Chrome/Firefox DevTools show real frame cost, long tasks, and layout thrashing that DevTools alone cannot.

  • app.config.performance — set it true (dev only) and Vue emits performance.mark / performance.measure entries for component init, compile, render, and patch, visible in the browser’s performance timeline.

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
app.config.performance = true
app.mount('#app')

Page-load optimisation

  • Bundle size — keep dependencies lean, prefer ES-module builds so bundlers tree-shake, and audit with npx vite-bundle-visualizer. Import only what you use from utility libraries.

  • Code splitting — Vite splits at every dynamic import(); combine it with router-level lazy loading (below) so a route’s code arrives only when visited.

  • SSR / SSG — server-rendering the first paint, or pre-rendering static routes at build time, cuts time-to-content for content-heavy pages. See Page Load Optimizations and Server-Side Rendering.

  • Assets and CDN — serve hashed static assets from a CDN with long-lived cache headers; preload the critical JS/CSS; use <link rel="modulepreload"> for the entry chunk.

Update-time optimisation

Once mounted, the cost is re-rendering. The Update Optimizations section:

  • v-memo — skip re-rendering a v-for row whose dependencies are unchanged (see Rendering, Render Functions, and Web Components).

  • shallowRef / shallowReactive — for large immutable payloads (API responses, editor documents), a shallow container tracks only the .value / top-level reassignment, not every nested property.

  • <KeepAlive> — cache component instances across toggles so a heavy tab or route is not rebuilt each time it reappears.

  • Virtualised lists — render only the visible window of a long list (e.g. vue-virtual-scroller).

  • Stable :key`s — key `v-for by a persistent id, never the array index, so patching reuses DOM instead of recreating it.

  • Avoid accidental reactivity — do not wrap large static config or class instances in reactive; use markRaw or a plain module constant.

  • Computed stability — since Vue 3.4 a computed whose recalculated result is === (or deep-equal for primitives) to the previous value does not trigger downstream effects. Return stable references from computeds and keep them side-effect free.

import { shallowRef, triggerRef, markRaw } from 'vue'

const rows = shallowRef([])                 // 10k-row grid -- track the array identity only
async function load() {
  rows.value = await fetchRows()            // reassign triggers; deep mutation would not
}

const chart = markRaw(new ChartLibrary())  // third-party instance, never made reactive

Async components and route splitting

defineAsyncComponent defers loading a component until it renders, with loading and error states:

import { defineAsyncComponent } from 'vue'

const CommentPanel = defineAsyncComponent({
  loader: () => import('./CommentPanel.vue'),
  loadingComponent: Spinner,
  errorComponent: LoadError,
  delay: 200,
  timeout: 8000,
})

Route-level splitting is the highest-value case — give the router a dynamic import and each route becomes its own chunk:

const routes = [
  { path: '/', component: () => import('./views/Home.vue') },
  { path: '/reports', component: () => import('./views/Reports.vue') },
]

Production feature flags

Bundlers replace these compile-time globals; setting the unused ones to false lets the minifier drop dead code. The Compile-Time Flags reference is authoritative.

Flag Effect when false

VUE_OPTIONS_API

drop Options API support from the bundle (only if the app and its deps are Composition-only)

VUE_PROD_DEVTOOLS

keep DevTools disabled in production (the default; set true only to debug a prod build)

VUE_PROD_HYDRATION_MISMATCH_DETAILS

omit verbose hydration-mismatch messages from the prod SSR build

With Vite these are pre-configured; override in vite.config.js:

export default {
  define: {
    __VUE_OPTIONS_API__: false,
    __VUE_PROD_DEVTOOLS__: false,
  },
}

Without a bundler that sets process.env.NODE_ENV, define these yourself or you ship the dev build.

Dev vs. prod builds

The development build includes the template compiler, warnings, dev-only invariant checks, and the DevTools bridge — several times the size and slower. The production path, per Production Deployment:

  • NODE_ENV=production — gates every if (DEV) warning block. vite build sets it automatically.

  • Ahead-of-time template compilation — SFCs are compiled to render functions at build time, so the runtime-only Vue build (no compiler) ships. Avoid runtime string templates in production.

  • vite build output — minified, hashed, tree-shaken JS/CSS plus an index.html in dist/. Inspect it with vite preview.

npm run build          # -> dist/  (vue-tsc type-check + vite build)
npm run preview        # serve dist/ locally to sanity-check the real bundle

Runtime error tracking

Wire a reporter so production errors reach your monitoring service:

const app = createApp(App)

app.config.errorHandler = (err, instance, info) => {
  // uncaught errors from render, watchers, lifecycle hooks, event handlers
  reportToService(err, { info })
}

app.config.warnHandler = (msg, instance, trace) => {
  // dev-only; silence known-noisy warnings or forward them
}

Component-local recovery uses the errorCaptured hook (or onErrorCaptured in setup): return false to stop propagation after showing a fallback UI. See app.config.errorHandler.

Deploying a static SPA

A built Vue SPA is static files. Two server-side requirements:

  • History-mode fallback — with createWebHistory, deep links like /reports/42 must serve index.html so the router can take over. Every host below needs a rewrite rule; see Vue Router — HTML5 History Mode.

  • Cache headers — hashed assets under /assets/ get Cache-Control: public, max-age=31536000, immutable; index.html gets no-cache so a deploy is picked up immediately.

Netlify

# netlify.toml
[build]
  command = "npm run build"
  publish = "dist"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

AWS S3 + CloudFront

Upload dist/ to a private bucket, serve it through CloudFront with Origin Access Control, and map error responses back to the app:

aws s3 sync dist/ s3://my-app-bucket --delete \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude index.html
aws s3 cp dist/index.html s3://my-app-bucket/index.html \
  --cache-control "no-cache"

# CloudFront: custom error responses 403 and 404 -> /index.html, response code 200
aws cloudfront create-invalidation --distribution-id ABCD1234 --paths "/index.html"

CI/CD pipeline

Lint, test, build, deploy. GitHub Actions:

name: deploy
on:
  push:
    branches: [main]
jobs:
  build-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run test:unit -- --run
      - run: npm run build
      - run: |
          aws s3 sync dist/ s3://my-app-bucket --delete
          aws cloudfront create-invalidation --distribution-id ABCD1234 --paths "/*"
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

The equivalent GitLab CI/CD:

stages: [test, build, deploy]

test:
  stage: test
  image: node:24
  script:
    - npm ci
    - npm run lint
    - npm run test:unit -- --run

build:
  stage: build
  image: node:24
  script:
    - npm ci
    - npm run build
  artifacts:
    paths: [dist/]

deploy:
  stage: deploy
  image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
  only: [main]
  script:
    - aws s3 sync dist/ s3://my-app-bucket --delete
    - aws cloudfront create-invalidation --distribution-id "$CF_DIST_ID" --paths "/*"

See also