Files
ForgeLM/.agents/skills/perf-optimization/languages/frontend.md
T

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 upgrades
  • vendor-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:

  1. Use <img loading="lazy"> for below-the-fold images
  2. Use WebP/AVIF formats (Vite handles this with plugins)
  3. Specify explicit width and height attributes (prevents CLS)
  4. Use <picture> with srcset for responsive images

Pattern: Watchers / Computed Overuse

Symptom: DevTools Performance tab shows repeated "Scripting" blocks during reactivity updates.

Fix:

  • Prefer computed over watch — computed values are lazy and cached
  • Use watchEffect only when side effects are truly needed
  • Use shallowRef / shallowReactive for 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:

  1. Verify navigator.serviceWorker.controller is active (DevTools → Application → Service Workers)
  2. Check Cache Storage in DevTools — are the expected caches populated?
  3. Verify runtimeCaching patterns in vite.config.ts match actual API URL patterns
  4. 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:

  1. No @import of the full Tailwind CSS build in production
  2. The @tailwindcss/vite plugin is used (it is — confirmed in your config)
  3. No dynamic class names that prevent tree-shaking (e.g., class={text-${color}-500})

Vue/Vite-Specific Anti-Patterns

  1. Don't eagerly import all routes. This defeats Vite's code splitting. Always use dynamic () => import() for route components.
  2. Don't use v-if + v-for on the same element. v-if has higher priority in Vue 3 — use a <template> wrapper.
  3. Don't deep-watch large arrays. Use shallowRef or watch(() => arr.length) to avoid O(n) reactivity tracking.
  4. Don't ignore key in v-for. Missing keys force Vue to re-render entire lists instead of patching individual items.
  5. Don't inline large SVGs when they could be <img> references. Inline SVGs increase HTML parse time and bundle size.
  6. Don't load Supabase JS in every component. Import the singleton client from supabaseClient.ts — avoid creating multiple GoTrue instances.
  7. Don't use plain functions for reactive template expressions. If v-if="isVisible()" calls a function, Vue re-executes it on every render cycle. Use computed instead — 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 bundle mode since it works everywhere and provides the most actionable data (chunk sizes, vendor splitting status, oversized chunk flags).

  1. Bundle analysis first: Run scripts/frontend-lighthouse.sh bundle ./apps/frontend to check chunk sizes
  2. Lighthouse desktop + mobile: Run scripts/frontend-lighthouse.sh lighthouse <url> desktop and default (mobile) — compare scores
  3. Runtime profiling: Use Chrome DevTools Performance tab to record user flows (navigation, exercise completion)
  4. Memory: Take heap snapshots before and after a user flow — diff for leaks (detached DOM, event listeners)
  5. 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-show conditions use computed refs, not plain function calls
  • v-for lists use stable :key values (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 defineAsyncComponent if they are conditionally rendered

Store Health

  • Stores used by multiple co-mounted views have a concurrent-fetch guard (if (isLoading) return)
  • No deep: true watchers on large arrays — use shallowRef + explicit trigger
  • Computed getters are used instead of methods for derived store state

Bundle Health

  • Vendor libraries are isolated into stable chunks (manualChunks)
  • CSS @import statements 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 warnings
  • npx vitest run — all tests pass
  • npx vue-tsc -b — no type errors
  • Build output diff — no unexpected chunk size regressions