SSR Performance Optimization with Element Plus
Overview
Optimizing Server-Side Rendering (SSR) performance is crucial for delivering fast, responsive applications. This guide covers comprehensive strategies for optimizing Element Plus SSR applications, from server-side optimizations to client-side hydration improvements.
Performance Metrics and Monitoring
Key SSR Performance Metrics
- Time to First Byte (TTFB): Server response time
- First Contentful Paint (FCP): When content first appears
- Largest Contentful Paint (LCP): When main content loads
- Time to Interactive (TTI): When page becomes interactive
- Hydration Time: Time to complete client-side hydration
- Cumulative Layout Shift (CLS): Visual stability
Performance Monitoring Setup
javascript
// utils/performance.js
export class PerformanceMonitor {
constructor() {
this.metrics = new Map()
this.observers = new Map()
this.setupObservers()
}
setupObservers() {
if (typeof window === 'undefined') return
// Core Web Vitals
this.observeWebVitals()
// Custom SSR metrics
this.observeSSRMetrics()
// Resource timing
this.observeResourceTiming()
}
observeWebVitals() {
// LCP Observer
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
this.recordMetric('LCP', lastEntry.startTime)
})
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
// FCP Observer
const fcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
const fcpEntry = entries.find(entry => entry.name === 'first-contentful-paint')
if (fcpEntry) {
this.recordMetric('FCP', fcpEntry.startTime)
}
})
fcpObserver.observe({ entryTypes: ['paint'] })
// CLS Observer
let clsValue = 0
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
}
}
this.recordMetric('CLS', clsValue)
})
clsObserver.observe({ entryTypes: ['layout-shift'] })
}
observeSSRMetrics() {
// Hydration timing
const hydrationStart = performance.mark('hydration-start')
// Listen for hydration complete
window.addEventListener('app:hydrated', () => {
performance.mark('hydration-end')
performance.measure('hydration-duration', 'hydration-start', 'hydration-end')
const measure = performance.getEntriesByName('hydration-duration')[0]
this.recordMetric('Hydration', measure.duration)
})
}
observeResourceTiming() {
const resourceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.includes('element-plus')) {
this.recordMetric(`Resource-${entry.name}`, entry.duration)
}
}
})
resourceObserver.observe({ entryTypes: ['resource'] })
}
recordMetric(name, value) {
this.metrics.set(name, {
value,
timestamp: Date.now(),
url: window.location.href
})
// Send to analytics
this.sendToAnalytics(name, value)
}
sendToAnalytics(name, value) {
// Send to your analytics service
if (window.gtag) {
window.gtag('event', 'performance_metric', {
metric_name: name,
metric_value: Math.round(value),
custom_parameter: window.location.pathname
})
}
}
getMetrics() {
return Object.fromEntries(this.metrics)
}
}
// Initialize performance monitoring
if (typeof window !== 'undefined') {
window.performanceMonitor = new PerformanceMonitor()
}
Server-Side Optimizations
Caching Strategies
Page-Level Caching
javascript
// server/cache.js
import Redis from 'ioredis'
import LRU from 'lru-cache'
class CacheManager {
constructor() {
// Redis for distributed caching
this.redis = new Redis(process.env.REDIS_URL)
// LRU for in-memory caching
this.memoryCache = new LRU({
max: 1000,
ttl: 1000 * 60 * 5 // 5 minutes
})
}
async get(key) {
// Try memory cache first
let value = this.memoryCache.get(key)
if (value) return value
// Try Redis cache
value = await this.redis.get(key)
if (value) {
const parsed = JSON.parse(value)
this.memoryCache.set(key, parsed)
return parsed
}
return null
}
async set(key, value, ttl = 300) {
// Set in memory cache
this.memoryCache.set(key, value)
// Set in Redis with TTL
await this.redis.setex(key, ttl, JSON.stringify(value))
}
generateCacheKey(req) {
const { url, headers } = req
const userAgent = headers['user-agent'] || ''
const isMobile = /Mobile|Android|iPhone|iPad/.test(userAgent)
return `page:${url}:${isMobile ? 'mobile' : 'desktop'}`
}
shouldCache(req) {
// Don't cache authenticated requests
if (req.headers.authorization || req.headers.cookie?.includes('auth')) {
return false
}
// Don't cache POST requests
if (req.method !== 'GET') {
return false
}
// Don't cache admin pages
if (req.url.startsWith('/admin')) {
return false
}
return true
}
}
export const cacheManager = new CacheManager()
// Express middleware for page caching
export const pageCacheMiddleware = async (req, res, next) => {
if (!cacheManager.shouldCache(req)) {
return next()
}
const cacheKey = cacheManager.generateCacheKey(req)
const cached = await cacheManager.get(cacheKey)
if (cached) {
res.set({
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=300',
'X-Cache': 'HIT'
})
return res.send(cached.html)
}
// Store original send method
const originalSend = res.send
// Override send to cache response
res.send = function(html) {
if (res.statusCode === 200 && typeof html === 'string') {
cacheManager.set(cacheKey, { html }, 300)
}
res.set('X-Cache', 'MISS')
return originalSend.call(this, html)
}
next()
}
Component-Level Caching
javascript
// server/componentCache.js
class ComponentCache {
constructor() {
this.cache = new Map()
this.maxSize = 10000
this.ttl = 1000 * 60 * 10 // 10 minutes
}
generateKey(component, props) {
return `${component}:${JSON.stringify(props)}`
}
get(component, props) {
const key = this.generateKey(component, props)
const cached = this.cache.get(key)
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.html
}
if (cached) {
this.cache.delete(key)
}
return null
}
set(component, props, html) {
if (this.cache.size >= this.maxSize) {
// Remove oldest entries
const entries = Array.from(this.cache.entries())
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
for (let i = 0; i < Math.floor(this.maxSize * 0.1); i++) {
this.cache.delete(entries[i][0])
}
}
const key = this.generateKey(component, props)
this.cache.set(key, {
html,
timestamp: Date.now()
})
}
}
export const componentCache = new ComponentCache()
// Vue component caching mixin
export const cacheable = {
beforeCreate() {
if (this.$isServer && this.$options.cache) {
const cached = componentCache.get(
this.$options.name,
this.$props
)
if (cached) {
this.$ssrContext.cached = true
return cached
}
}
},
created() {
if (this.$isServer && this.$options.cache && !this.$ssrContext.cached) {
const originalRender = this.$options.render
this.$options.render = function(h) {
const result = originalRender.call(this, h)
// Cache the rendered result
if (result && typeof result === 'string') {
componentCache.set(
this.$options.name,
this.$props,
result
)
}
return result
}
}
}
}
Streaming SSR
javascript
// server/streaming.js
import { renderToNodeStream } from 'vue/server-renderer'
import { Transform } from 'stream'
class SSRStream extends Transform {
constructor(options = {}) {
super({ objectMode: true })
this.template = options.template
this.context = options.context
this.headerSent = false
}
_transform(chunk, encoding, callback) {
if (!this.headerSent) {
// Send HTML head immediately
const head = this.template.split('<!--ssr-outlet-->')[0]
this.push(head)
this.headerSent = true
}
// Stream the chunk
this.push(chunk)
callback()
}
_flush(callback) {
// Send closing HTML
const tail = this.template.split('<!--ssr-outlet-->')[1]
this.push(tail)
callback()
}
}
export const streamingSSR = async (req, res, app, template) => {
try {
// Set headers for streaming
res.setHeader('Content-Type', 'text/html')
res.setHeader('Transfer-Encoding', 'chunked')
// Create streaming renderer
const stream = renderToNodeStream(app)
const ssrStream = new SSRStream({ template })
// Handle stream errors
stream.on('error', (error) => {
console.error('SSR Stream Error:', error)
if (!res.headersSent) {
res.status(500).send('Internal Server Error')
}
})
// Pipe the stream
stream.pipe(ssrStream).pipe(res)
} catch (error) {
console.error('Streaming SSR Error:', error)
res.status(500).send('Internal Server Error')
}
}
Resource Optimization
Bundle Splitting
javascript
// vite.config.js - Advanced bundle splitting
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Vendor chunks
'vue-vendor': ['vue', 'vue-router'],
'element-plus': ['element-plus'],
'element-icons': ['@element-plus/icons-vue'],
// Feature-based chunks
'charts': ['echarts', 'vue-echarts'],
'forms': ['async-validator', 'normalize-wheel-es'],
'utils': ['lodash-es', 'dayjs'],
// Route-based chunks
'admin': [
'./src/pages/admin/index.vue',
'./src/pages/admin/users.vue',
'./src/pages/admin/settings.vue'
],
'dashboard': [
'./src/pages/dashboard/index.vue',
'./src/pages/dashboard/analytics.vue'
]
},
// Optimize chunk loading
chunkFileNames: (chunkInfo) => {
const facadeModuleId = chunkInfo.facadeModuleId
if (facadeModuleId) {
const name = facadeModuleId.split('/').pop().replace('.vue', '')
return `chunks/${name}-[hash].js`
}
return 'chunks/[name]-[hash].js'
}
}
},
// Enable tree shaking
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.info']
}
}
}
})
CSS Optimization
javascript
// vite.config.js - CSS optimization
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
// Optimize SCSS compilation
additionalData: `
@use "sass:math";
@import "~/assets/scss/variables.scss";
`
}
},
// PostCSS optimization
postcss: {
plugins: [
require('autoprefixer'),
require('cssnano')({
preset: ['default', {
discardComments: { removeAll: true },
normalizeWhitespace: true,
minifySelectors: true
}]
})
]
}
},
// Extract CSS
build: {
cssCodeSplit: true,
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name.endsWith('.css')) {
return 'css/[name]-[hash].css'
}
return 'assets/[name]-[hash][extname]'
}
}
}
}
})
Client-Side Optimizations
Hydration Optimization
javascript
// utils/hydration.js
export class HydrationOptimizer {
constructor() {
this.hydrationQueue = []
this.isHydrating = false
this.priorityComponents = new Set([
'ElButton',
'ElInput',
'ElForm',
'ElDialog'
])
}
scheduleHydration(component, priority = 'normal') {
this.hydrationQueue.push({ component, priority })
if (!this.isHydrating) {
this.processQueue()
}
}
async processQueue() {
this.isHydrating = true
// Sort by priority
this.hydrationQueue.sort((a, b) => {
if (a.priority === 'high' && b.priority !== 'high') return -1
if (a.priority !== 'high' && b.priority === 'high') return 1
return 0
})
// Process in chunks to avoid blocking
while (this.hydrationQueue.length > 0) {
const chunk = this.hydrationQueue.splice(0, 5)
await Promise.all(
chunk.map(({ component }) => this.hydrateComponent(component))
)
// Yield to browser
await new Promise(resolve => setTimeout(resolve, 0))
}
this.isHydrating = false
// Emit hydration complete event
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('app:hydrated'))
}
}
async hydrateComponent(component) {
return new Promise((resolve) => {
// Use requestIdleCallback if available
if (window.requestIdleCallback) {
window.requestIdleCallback(() => {
component.hydrate()
resolve()
})
} else {
setTimeout(() => {
component.hydrate()
resolve()
}, 0)
}
})
}
isPriorityComponent(componentName) {
return this.priorityComponents.has(componentName)
}
}
export const hydrationOptimizer = new HydrationOptimizer()
Progressive Hydration
vue
<!-- components/ProgressiveHydration.vue -->
<template>
<div ref="container">
<slot v-if="isHydrated" />
<div v-else v-html="ssrContent" />
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
const props = defineProps({
when: {
type: String,
default: 'visible', // 'visible', 'idle', 'interaction', 'immediate'
validator: (value) => ['visible', 'idle', 'interaction', 'immediate'].includes(value)
},
rootMargin: {
type: String,
default: '0px'
}
})
const container = ref(null)
const isHydrated = ref(false)
const ssrContent = ref('')
// Store SSR content before hydration
if (typeof window !== 'undefined' && container.value) {
ssrContent.value = container.value.innerHTML
}
const hydrate = async () => {
if (isHydrated.value) return
// Mark as hydrating
isHydrated.value = true
// Wait for next tick to ensure DOM updates
await nextTick()
// Emit hydration event
container.value?.dispatchEvent(new CustomEvent('component:hydrated'))
}
const setupHydrationTrigger = () => {
switch (props.when) {
case 'immediate':
hydrate()
break
case 'visible':
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
hydrate()
observer.disconnect()
}
},
{ rootMargin: props.rootMargin }
)
observer.observe(container.value)
} else {
hydrate() // Fallback for older browsers
}
break
case 'idle':
if ('requestIdleCallback' in window) {
window.requestIdleCallback(hydrate)
} else {
setTimeout(hydrate, 100)
}
break
case 'interaction':
const events = ['click', 'keydown', 'touchstart', 'mouseover']
const handleInteraction = () => {
hydrate()
events.forEach(event => {
container.value?.removeEventListener(event, handleInteraction)
})
}
events.forEach(event => {
container.value?.addEventListener(event, handleInteraction, { once: true })
})
break
}
}
onMounted(() => {
setupHydrationTrigger()
})
</script>
Lazy Loading Components
javascript
// utils/lazyComponents.js
export const createLazyComponent = (importFn, options = {}) => {
const {
loading: LoadingComponent,
error: ErrorComponent,
delay = 200,
timeout = 10000
} = options
return defineAsyncComponent({
loader: importFn,
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay,
timeout,
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
// Retry up to 3 times
setTimeout(retry, 1000 * attempts)
} else {
fail()
}
}
})
}
// Pre-defined lazy components
export const LazyDataTable = createLazyComponent(
() => import('../components/DataTable.vue'),
{
loading: () => h('div', { class: 'loading-skeleton' }, 'Loading table...'),
error: () => h('div', { class: 'error-message' }, 'Failed to load table')
}
)
export const LazyChart = createLazyComponent(
() => import('../components/Chart.vue'),
{
loading: () => h('el-skeleton', { rows: 5, animated: true }),
error: () => h('el-alert', {
title: 'Chart Loading Error',
type: 'error',
'show-icon': true
})
}
)
export const LazyRichEditor = createLazyComponent(
() => import('../components/RichEditor.vue'),
{
loading: () => h('el-skeleton', { rows: 10, animated: true }),
delay: 500 // Delay loading for better UX
}
)
Image Optimization
vue
<!-- components/OptimizedImage.vue -->
<template>
<div class="optimized-image" :class="{ loaded: isLoaded }">
<img
v-if="shouldLoad"
:src="optimizedSrc"
:alt="alt"
:loading="loading"
:decoding="decoding"
@load="handleLoad"
@error="handleError"
class="main-image"
/>
<!-- Placeholder -->
<div v-if="!isLoaded" class="placeholder">
<el-skeleton v-if="showSkeleton" animated />
<div v-else class="placeholder-color" :style="placeholderStyle" />
</div>
<!-- Low quality placeholder -->
<img
v-if="lqip && !isLoaded"
:src="lqip"
:alt="alt"
class="lqip"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
src: { type: String, required: true },
alt: { type: String, required: true },
width: { type: [Number, String] },
height: { type: [Number, String] },
quality: { type: Number, default: 80 },
format: { type: String, default: 'webp' },
loading: { type: String, default: 'lazy' },
decoding: { type: String, default: 'async' },
lqip: { type: String }, // Low Quality Image Placeholder
placeholder: { type: String, default: '#f0f0f0' },
showSkeleton: { type: Boolean, default: false },
lazy: { type: Boolean, default: true }
})
const isLoaded = ref(false)
const shouldLoad = ref(!props.lazy)
const observer = ref(null)
const imageRef = ref(null)
const optimizedSrc = computed(() => {
if (!props.src) return ''
// Build optimized image URL
const url = new URL(props.src, window.location.origin)
if (props.width) url.searchParams.set('w', props.width.toString())
if (props.height) url.searchParams.set('h', props.height.toString())
if (props.quality) url.searchParams.set('q', props.quality.toString())
if (props.format) url.searchParams.set('f', props.format)
return url.toString()
})
const placeholderStyle = computed(() => ({
backgroundColor: props.placeholder,
width: props.width ? `${props.width}px` : '100%',
height: props.height ? `${props.height}px` : '200px'
}))
const handleLoad = () => {
isLoaded.value = true
}
const handleError = () => {
console.error('Failed to load image:', props.src)
}
const setupIntersectionObserver = () => {
if (!props.lazy || typeof window === 'undefined') return
observer.value = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
shouldLoad.value = true
observer.value?.disconnect()
}
},
{ rootMargin: '50px' }
)
if (imageRef.value) {
observer.value.observe(imageRef.value)
}
}
onMounted(() => {
if (props.lazy) {
setupIntersectionObserver()
} else {
shouldLoad.value = true
}
})
onUnmounted(() => {
observer.value?.disconnect()
})
</script>
<style scoped>
.optimized-image {
position: relative;
overflow: hidden;
}
.main-image {
width: 100%;
height: auto;
transition: opacity 0.3s ease;
}
.placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-color {
width: 100%;
height: 100%;
border-radius: 4px;
}
.lqip {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(5px);
transition: opacity 0.3s ease;
}
.loaded .lqip {
opacity: 0;
}
.loaded .placeholder {
opacity: 0;
}
</style>
Advanced Optimization Techniques
Service Worker for Caching
javascript
// public/sw.js
const CACHE_NAME = 'element-plus-app-v1'
const STATIC_CACHE = 'static-v1'
const DYNAMIC_CACHE = 'dynamic-v1'
const STATIC_ASSETS = [
'/',
'/css/app.css',
'/js/app.js',
'/fonts/element-icons.woff2'
]
// Install event
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
)
})
// Activate event
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map(name => caches.delete(name))
)
})
.then(() => self.clients.claim())
)
})
// Fetch event
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// Handle API requests
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request))
return
}
// Handle static assets
if (STATIC_ASSETS.includes(url.pathname)) {
event.respondWith(cacheFirst(request))
return
}
// Handle pages
if (request.mode === 'navigate') {
event.respondWith(staleWhileRevalidate(request))
return
}
// Default: network first
event.respondWith(networkFirst(request))
})
// Cache strategies
async function cacheFirst(request) {
const cached = await caches.match(request)
return cached || fetch(request)
}
async function networkFirst(request) {
try {
const response = await fetch(request)
const cache = await caches.open(DYNAMIC_CACHE)
cache.put(request, response.clone())
return response
} catch (error) {
return caches.match(request)
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(DYNAMIC_CACHE)
const cached = await cache.match(request)
const fetchPromise = fetch(request).then(response => {
cache.put(request, response.clone())
return response
})
return cached || fetchPromise
}
Critical CSS Extraction
javascript
// build/critical-css.js
import { PurgeCSS } from 'purgecss'
import critical from 'critical'
import fs from 'fs/promises'
import path from 'path'
export async function extractCriticalCSS() {
const routes = [
'/',
'/about',
'/contact',
'/dashboard'
]
for (const route of routes) {
try {
const { css } = await critical.generate({
base: 'dist/',
src: `index.html`,
target: {
css: `critical${route.replace('/', '-') || '-home'}.css`,
html: `optimized${route.replace('/', '-') || '-home'}.html`
},
width: 1300,
height: 900,
minify: true,
extract: true,
inlineImages: true
})
console.log(`Critical CSS extracted for ${route}`)
} catch (error) {
console.error(`Failed to extract critical CSS for ${route}:`, error)
}
}
}
export async function purgeUnusedCSS() {
const purgeCSSResult = await new PurgeCSS().purge({
content: [
'dist/**/*.html',
'dist/**/*.js'
],
css: ['dist/**/*.css'],
safelist: {
standard: [
/^el-/,
/^is-/,
/^has-/,
'active',
'disabled',
'loading'
],
deep: [
/^el-.*__/,
/^el-.*--/
]
}
})
for (const result of purgeCSSResult) {
await fs.writeFile(result.file, result.css)
}
console.log('Unused CSS purged')
}
Performance Testing
Automated Performance Testing
javascript
// tests/performance.test.js
import { test, expect } from '@playwright/test'
import { injectSpeedInsights } from '@vercel/speed-insights'
test.describe('Performance Tests', () => {
test('should meet Core Web Vitals thresholds', async ({ page }) => {
// Navigate to page
await page.goto('/')
// Measure performance metrics
const metrics = await page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries()
const metrics = {}
entries.forEach(entry => {
if (entry.entryType === 'largest-contentful-paint') {
metrics.LCP = entry.startTime
}
if (entry.entryType === 'first-input') {
metrics.FID = entry.processingStart - entry.startTime
}
if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
metrics.CLS = (metrics.CLS || 0) + entry.value
}
})
resolve(metrics)
}).observe({ entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift'] })
// Timeout after 10 seconds
setTimeout(() => resolve({}), 10000)
})
})
// Assert Core Web Vitals thresholds
if (metrics.LCP) expect(metrics.LCP).toBeLessThan(2500) // Good LCP < 2.5s
if (metrics.FID) expect(metrics.FID).toBeLessThan(100) // Good FID < 100ms
if (metrics.CLS) expect(metrics.CLS).toBeLessThan(0.1) // Good CLS < 0.1
})
test('should load within performance budget', async ({ page }) => {
const startTime = Date.now()
await page.goto('/')
await page.waitForLoadState('networkidle')
const loadTime = Date.now() - startTime
// Assert load time budget
expect(loadTime).toBeLessThan(3000) // 3 seconds
})
test('should have optimal bundle sizes', async ({ page }) => {
const response = await page.goto('/')
const html = await response.text()
// Extract script and CSS links
const scripts = html.match(/<script[^>]*src="([^"]*)"/g) || []
const styles = html.match(/<link[^>]*href="([^"]*\.css)"/g) || []
let totalJSSize = 0
let totalCSSSize = 0
// Measure JS bundle sizes
for (const script of scripts) {
const src = script.match(/src="([^"]*)"/)?.[1]
if (src && !src.startsWith('http')) {
const response = await page.request.get(src)
totalJSSize += (await response.body()).length
}
}
// Measure CSS bundle sizes
for (const style of styles) {
const href = style.match(/href="([^"]*)"/)?.[1]
if (href && !href.startsWith('http')) {
const response = await page.request.get(href)
totalCSSSize += (await response.body()).length
}
}
// Assert bundle size budgets
expect(totalJSSize).toBeLessThan(500 * 1024) // 500KB JS budget
expect(totalCSSSize).toBeLessThan(100 * 1024) // 100KB CSS budget
})
})
Monitoring and Analytics
Real User Monitoring (RUM)
javascript
// utils/rum.js
class RealUserMonitoring {
constructor(config = {}) {
this.config = {
endpoint: '/api/metrics',
sampleRate: 0.1, // 10% sampling
...config
}
this.metrics = new Map()
this.init()
}
init() {
if (typeof window === 'undefined') return
// Sample users based on rate
if (Math.random() > this.config.sampleRate) return
this.observePerformance()
this.observeErrors()
this.observeUserInteractions()
// Send metrics on page unload
window.addEventListener('beforeunload', () => {
this.sendMetrics()
})
// Send metrics periodically
setInterval(() => {
this.sendMetrics()
}, 30000) // Every 30 seconds
}
observePerformance() {
// Navigation timing
const navigation = performance.getEntriesByType('navigation')[0]
if (navigation) {
this.recordMetric('TTFB', navigation.responseStart - navigation.requestStart)
this.recordMetric('DOMContentLoaded', navigation.domContentLoadedEventEnd - navigation.navigationStart)
this.recordMetric('LoadComplete', navigation.loadEventEnd - navigation.navigationStart)
}
// Core Web Vitals
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
this.recordMetric('LCP', entry.startTime)
}
}
}).observe({ entryTypes: ['largest-contentful-paint'] })
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.recordMetric('FID', entry.processingStart - entry.startTime)
}
}).observe({ entryTypes: ['first-input'] })
let clsValue = 0
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
this.recordMetric('CLS', clsValue)
}
}
}).observe({ entryTypes: ['layout-shift'] })
}
observeErrors() {
window.addEventListener('error', (event) => {
this.recordError({
type: 'javascript',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack
})
})
window.addEventListener('unhandledrejection', (event) => {
this.recordError({
type: 'promise',
message: event.reason?.message || 'Unhandled Promise Rejection',
stack: event.reason?.stack
})
})
}
observeUserInteractions() {
let interactionCount = 0
['click', 'keydown', 'scroll'].forEach(eventType => {
window.addEventListener(eventType, () => {
interactionCount++
this.recordMetric('Interactions', interactionCount)
}, { passive: true })
})
}
recordMetric(name, value) {
this.metrics.set(name, {
value,
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent,
connection: navigator.connection?.effectiveType
})
}
recordError(error) {
this.metrics.set(`Error-${Date.now()}`, {
...error,
timestamp: Date.now(),
url: window.location.href,
userAgent: navigator.userAgent
})
}
async sendMetrics() {
if (this.metrics.size === 0) return
const data = Object.fromEntries(this.metrics)
try {
await fetch(this.config.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
metrics: data,
session: this.getSessionId(),
timestamp: Date.now()
})
})
this.metrics.clear()
} catch (error) {
console.error('Failed to send metrics:', error)
}
}
getSessionId() {
let sessionId = sessionStorage.getItem('rum-session-id')
if (!sessionId) {
sessionId = Math.random().toString(36).substring(2, 15)
sessionStorage.setItem('rum-session-id', sessionId)
}
return sessionId
}
}
// Initialize RUM
if (typeof window !== 'undefined') {
new RealUserMonitoring({
endpoint: '/api/rum',
sampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0
})
}
Best Practices Summary
Server-Side
- Implement Multi-Level Caching: Page, component, and data caching
- Use Streaming SSR: Improve TTFB with streaming responses
- Optimize Bundle Splitting: Strategic code splitting for optimal loading
- Enable Compression: Gzip/Brotli compression for all assets
- CDN Integration: Serve static assets from CDN
Client-Side
- Progressive Hydration: Hydrate components based on priority and visibility
- Lazy Loading: Load components and images on demand
- Service Worker: Implement caching strategies with service workers
- Critical CSS: Extract and inline critical CSS
- Resource Hints: Use preload, prefetch, and preconnect
Monitoring
- Core Web Vitals: Monitor LCP, FID, and CLS
- Real User Monitoring: Collect performance data from real users
- Error Tracking: Monitor and track JavaScript errors
- Performance Budgets: Set and enforce performance budgets
- Continuous Testing: Automated performance testing in CI/CD
By implementing these optimization strategies, you can achieve excellent SSR performance with Element Plus, providing users with fast, responsive applications while maintaining good SEO and accessibility.