13 KiB
Frontend Performance Profiling — Vue 3 + Vite + PWA
Stack Context
| Layer | Tool | Version |
|---|---|---|
| Framework | Vue 3 (SFC + Composition API) | ^3.5 |
| State | Pinia | ^3.0 |
| Router | Vue Router | ^5.0 |
| Bundler | Vite | ^7.3 |
| CSS | Tailwind CSS (Vite plugin) | ^4.2 |
| PWA | vite-plugin-pwa (Workbox) | ^1.2 |
| Backend client | Supabase JS | ^2.99 |
| Build | vue-tsc + vite build | — |
| Tests | Vitest + jsdom | ^4.1 |
Profiling Toolchain
Build-Time Analysis
| Tool | Purpose | Command |
|---|---|---|
| Vite bundle visualizer | Chunk sizes, tree-shaking effectiveness | npx vite-bundle-visualizer (generates stats.html) |
vite build --report |
Build timing and chunk summary | npx vite build 2>&1 |
source-map-explorer |
Trace bytes to source files | npx source-map-explorer dist/assets/*.js |
bundlephobia |
Check dependency size before adding | npx bundlephobia <package-name> |
Quick bundle size check (no extra dependency needed):
# Build and report chunk sizes
cd apps/frontend && npx vite build 2>&1 | grep -E "(dist/|\.js|\.css)" | sort -t'│' -k2 -rn
Detailed analysis with rollup-plugin-visualizer:
# Install temporarily and run
cd apps/frontend && npx vite-bundle-visualizer
# Opens stats.html in browser — shows treemap of every module
Runtime Analysis (Chrome DevTools)
| Tab | What it measures | When to use |
|---|---|---|
| Performance | Frame rate, scripting, rendering, painting | Janky animations, slow interactions |
| Lighthouse | LCP, FID/INP, CLS, TTI, bundle size | Overall page health score |
| Network | Waterfall, asset sizes, cache status | Slow loads, unnecessary requests |
| Memory | Heap snapshots, allocation timeline | Memory leaks, growing detached DOM |
| Coverage | Used vs unused JS/CSS bytes | Dead code, over-imported libraries |
Core Web Vitals
| Metric | Target | What it measures |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | How fast the main content appears |
| INP (Interaction to Next Paint) | < 200ms | How fast interactions respond |
| CLS (Cumulative Layout Shift) | < 0.1 | How much the layout shifts during load |
| TTFB (Time to First Byte) | < 800ms | Server response time |
| FCP (First Contentful Paint) | < 1.8s | How fast anything renders |
Lighthouse CLI (Headless — for Scripted Analysis)
# Run Lighthouse from CLI (no browser GUI needed)
npx lighthouse http://localhost:4173 \
--output=json \
--output-path=./lighthouse-report.json \
--chrome-flags="--headless --no-sandbox" \
--only-categories=performance \
--preset=desktop
# For mobile (default):
npx lighthouse http://localhost:4173 \
--output=json \
--output-path=./lighthouse-report.json \
--chrome-flags="--headless --no-sandbox" \
--only-categories=performance
Agent-readable extraction:
# Extract key metrics from Lighthouse JSON
cat lighthouse-report.json | node -e "
const r = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
const a = r.audits;
console.log('| Metric | Score | Value |');
console.log('|---|---|---|');
['first-contentful-paint','largest-contentful-paint','total-blocking-time',
'cumulative-layout-shift','speed-index','interactive'].forEach(k => {
if(a[k]) console.log('| ' + a[k].title + ' | ' + (a[k].score*100) + ' | ' + a[k].displayValue + ' |');
});
"
Vue/Vite-Specific Optimization Patterns
Pattern: Vendor Chunk Splitting (Vite)
Symptom: Every deploy invalidates the entire JS bundle cache, including stable vendor libs (Vue, Supabase JS). See agnostic principle: Artifact Partitioning by Change Frequency in SKILL.md.
Fix: Add manualChunks to vite.config.ts:
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-supabase': ['@supabase/supabase-js'],
},
},
},
},
Group by change frequency:
vendor-vue: framework runtime — changes only on major upgradesvendor-supabase: SDK/client — changes infrequently- App code (auto): changes every deploy
Verification: After vite build, check dist/assets/ for vendor-vue-*.js and vendor-supabase-*.js chunks.
Pattern: Resource Hint Pipeline (HTML)
Symptom: Fonts load late because browser discovers them only after parsing CSS. See agnostic principle: Dependency Discovery Parallelization in SKILL.md.
Fix: Replace CSS @import with HTML <link> tags and add preconnect hints:
<!-- In index.html <head> — BEFORE the CSS link -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=...&display=swap">
Then remove the @import url(...) from your CSS file.
Safety: Only preconnect to domains used on every page load. For runtime-determined domains (e.g., Supabase URL from env vars), skip the hint — hardcoding would break across environments.
Pattern: Route-Level Code Splitting
Symptom: Lighthouse reports a large initial JS bundle with low Coverage score. All routes are in a single chunk.
Fix: Use dynamic imports for route components in Vue Router:
// ❌ Eager import — everything in one chunk
import SiraatView from '@/views/SiraatView.vue'
// ✅ Lazy route — separate chunk, loaded on navigation
const SiraatView = () => import('@/views/SiraatView.vue')
Verification: After build, check dist/assets/ — each lazy route should produce a separate .js chunk.
Pattern: Component Lazy Loading
Symptom: Heavy components (charts, editors, modals) are loaded even when not visible.
Fix: Use defineAsyncComponent for below-the-fold or conditionally-rendered components:
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() =>
import('@/features/analytics/HeavyChart.vue')
)
Pattern: Pinia Store Hydration
Symptom: Multiple API calls for the same data on page load. Network waterfall shows redundant fetches.
Fix: Deduplicate fetches in Pinia stores with loading-state guards:
const store = defineStore('lesson', () => {
const data = ref<Lesson | null>(null)
const loading = ref(false)
async function fetch(id: string) {
if (loading.value) return // ← prevent double-fetch
if (data.value?.id === id) return // ← already have it
loading.value = true
try {
data.value = await api.getLesson(id)
} finally {
loading.value = false
}
}
return { data, loading, fetch }
})
Pattern: Image Optimization
Symptom: Lighthouse flags large images (LCP degradation). Network shows uncompressed PNGs.
Fix checklist:
- Use
<img loading="lazy">for below-the-fold images - Use WebP/AVIF formats (Vite handles this with plugins)
- Specify explicit
widthandheightattributes (prevents CLS) - Use
<picture>withsrcsetfor responsive images
Pattern: Watchers / Computed Overuse
Symptom: DevTools Performance tab shows repeated "Scripting" blocks during reactivity updates.
Fix:
- Prefer
computedoverwatch— computed values are lazy and cached - Use
watchEffectonly when side effects are truly needed - Use
shallowRef/shallowReactivefor large objects that don't need deep reactivity
// ❌ Deep reactive on a 1000-item array — Vue tracks every nested property
const items = ref<Item[]>([])
// ✅ Shallow — Vue only tracks the array reference, not individual items
const items = shallowRef<Item[]>([])
Pattern: Virtual Scrolling for Long Lists
Symptom: Rendering > 100 DOM nodes causes jank. DevTools shows long "Rendering" and "Painting" phases.
Fix: Use virtual scrolling (only render visible items):
npm install @tanstack/vue-virtual
Pattern: PWA Cache Strategy Audit
Symptom: Repeat visits are slow despite service worker. Network tab shows cache misses.
Audit checklist:
- Verify
navigator.serviceWorker.controlleris active (DevTools → Application → Service Workers) - Check
Cache Storagein DevTools — are the expected caches populated? - Verify
runtimeCachingpatterns invite.config.tsmatch actual API URL patterns - Check
networkTimeoutSeconds— too high means slow fallback to cache
Pattern: Tailwind CSS Purging
Symptom: Large CSS bundle despite using Tailwind. Build output shows > 50KB CSS.
Fix: Tailwind v4 (your version) uses automatic content detection. Verify:
- No
@importof the full Tailwind CSS build in production - The
@tailwindcss/viteplugin is used (it is — confirmed in your config) - No dynamic class names that prevent tree-shaking (e.g.,
class={text-${color}-500})
Vue/Vite-Specific Anti-Patterns
- Don't eagerly import all routes. This defeats Vite's code splitting. Always use dynamic
() => import()for route components. - Don't use
v-if+v-foron the same element.v-ifhas higher priority in Vue 3 — use a<template>wrapper. - Don't deep-watch large arrays. Use
shallowReforwatch(() => arr.length)to avoid O(n) reactivity tracking. - Don't ignore
keyinv-for. Missing keys force Vue to re-render entire lists instead of patching individual items. - Don't inline large SVGs when they could be
<img>references. Inline SVGs increase HTML parse time and bundle size. - Don't load Supabase JS in every component. Import the singleton client from
supabaseClient.ts— avoid creating multiple GoTrue instances. - Don't use plain functions for reactive template expressions. If
v-if="isVisible()"calls a function, Vue re-executes it on every render cycle. Usecomputedinstead — Vue caches the result and only re-runs when dependencies change.
Irreducible Floors in Vue/Vite/Browser
| Cost | What it is | Why it's irreducible |
|---|---|---|
| Vue runtime | Reactivity system, virtual DOM diffing | Framework overhead — ~30KB min gzipped |
| Supabase JS | GoTrue + PostgREST client | Auth + API client — ~40KB min gzipped |
| TLS handshake | HTTPS connection setup | Network latency — mitigated by HTTP/2, preconnect |
| Font loading | WOFF2 download + layout shift | Use font-display: swap and preload critical fonts |
| Service Worker registration | SW parse + install on first visit | One-time cost per SW update |
| DNS resolution | Supabase API domain lookup | Use <link rel="dns-prefetch"> |
Data Extraction Scripts
Use the scripts in scripts/ for automated profiling:
# Always-works bundle analysis (no Chrome needed) — start here
scripts/frontend-lighthouse.sh bundle ./apps/frontend
# Lighthouse Core Web Vitals (needs Chrome/Chromium)
scripts/frontend-lighthouse.sh lighthouse http://localhost:4173 desktop
# With custom timeout (default: 120s)
LIGHTHOUSE_TIMEOUT=60 scripts/frontend-lighthouse.sh lighthouse http://localhost:4173
Agent tip: If Lighthouse hangs (common in headless/CI environments), the script auto-kills after the timeout and suggests bundle mode. Always start with
bundlemode since it works everywhere and provides the most actionable data (chunk sizes, vendor splitting status, oversized chunk flags).
Recommended Profiling Workflow
- Bundle analysis first: Run
scripts/frontend-lighthouse.sh bundle ./apps/frontendto check chunk sizes - Lighthouse desktop + mobile: Run
scripts/frontend-lighthouse.sh lighthouse <url> desktopand default (mobile) — compare scores - Runtime profiling: Use Chrome DevTools Performance tab to record user flows (navigation, exercise completion)
- Memory: Take heap snapshots before and after a user flow — diff for leaks (detached DOM, event listeners)
- Coverage: Run Coverage tool — identify dead JS/CSS shipped to the client
Post-Optimization Clean Sweep Checklist
After applying the main fixes, run this checklist to catch leftover issues. This was codified from the FATH frontend session (2026-03-23).
Template Reactivity
- All
v-if/v-showconditions usecomputedrefs, not plain function calls v-forlists use stable:keyvalues (not array index)- No plain functions in template expressions that could be
computed
Component Loading
- All routes use
() => import()(lazy routes) - Modals, drawers, and sheets use
defineAsyncComponent(they are never visible on initial render) - Heavy sub-components within lazy routes use
defineAsyncComponentif they are conditionally rendered
Store Health
- Stores used by multiple co-mounted views have a concurrent-fetch guard (
if (isLoading) return) - No
deep: truewatchers on large arrays — useshallowRef+ explicit trigger - Computed getters are used instead of methods for derived store state
Bundle Health
- Vendor libraries are isolated into stable chunks (
manualChunks) - CSS
@importstatements for external stylesheets (fonts, etc.) are replaced with HTML<link>tags <link rel="preconnect">hints exist for all statically-known third-party origins- Supabase JS is imported only once from a singleton module, not in individual components
Build Verification
npx vite build— clean output, no warningsnpx vitest run— all tests passnpx vue-tsc -b— no type errors- Build output diff — no unexpected chunk size regressions