Loading...
Loading...
Web Vitals, Lighthouse CI, bundle optimization, CDN, caching, and load testing
npx skill4agent add travisjneuman/.claude performance-engineering// Real User Monitoring (RUM) with web-vitals library
import { onLCP, onINP, onCLS, onFCP, onTTFB } from "web-vitals";
interface VitalMetric {
name: string;
value: number;
rating: "good" | "needs-improvement" | "poor";
navigationType: string;
}
function sendToAnalytics(metric: VitalMetric) {
// Send to your analytics endpoint
navigator.sendBeacon("/api/vitals", JSON.stringify({
...metric,
url: window.location.href,
userAgent: navigator.userAgent,
connectionType: (navigator as unknown as { connection?: { effectiveType: string } })
.connection?.effectiveType ?? "unknown",
timestamp: Date.now(),
}));
}
// Capture all Core Web Vitals
onLCP((metric) => sendToAnalytics({
name: "LCP",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
onINP((metric) => sendToAnalytics({
name: "INP",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
onCLS((metric) => sendToAnalytics({
name: "CLS",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
onFCP((metric) => sendToAnalytics({
name: "FCP",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));
onTTFB((metric) => sendToAnalytics({
name: "TTFB",
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}));// Lighthouse CI configuration
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: [
"http://localhost:3000/",
"http://localhost:3000/dashboard",
"http://localhost:3000/pricing",
],
numberOfRuns: 3,
},
assert: {
assertions: {
"categories:performance": ["error", { minScore: 0.9 }],
"categories:accessibility": ["error", { minScore: 0.95 }],
"first-contentful-paint": ["warn", { maxNumericValue: 1800 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
"interactive": ["error", { maxNumericValue: 3800 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
"total-byte-weight": ["warn", { maxNumericValue: 500000 }],
},
},
upload: {
target: "temporary-public-storage",
},
},
};// Next.js - Dynamic imports for route-based code splitting
import dynamic from "next/dynamic";
// Heavy component loaded only when needed
const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
loading: () => <div className="editor-skeleton" aria-busy="true">Loading editor...</div>,
ssr: false, // Client-only component
});
const ChartDashboard = dynamic(() => import("@/components/ChartDashboard"), {
loading: () => <ChartSkeleton />,
});
// Conditional feature loading
function ProjectPage({ project }: { project: Project }) {
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<h1>{project.name}</h1>
<button onClick={() => setShowEditor(true)}>Edit Description</button>
{showEditor && <RichTextEditor content={project.description} />}
</div>
);
}// webpack-bundle-analyzer integration
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({
// Analyze specific packages for tree-shaking
experimental: {
optimizePackageImports: ["lodash-es", "date-fns", "@mui/material", "lucide-react"],
},
});# Analyze bundle
ANALYZE=true npm run build
# Check specific import costs
npx import-cost # VS Code extension alternative
npx source-map-explorer .next/static/chunks/*.js// Next.js Image component with proper optimization
import Image from "next/image";
// Responsive hero image
function HeroSection() {
return (
<section>
<Image
src="/hero.jpg"
alt="Team collaborating on a project"
width={1920}
height={1080}
priority // LCP element - skip lazy loading
sizes="100vw"
quality={85}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQ..." // Low-quality placeholder
/>
</section>
);
}
// Responsive card image
function ProductCard({ product }: { product: Product }) {
return (
<article>
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
loading="lazy" // Below the fold
/>
<h3>{product.name}</h3>
</article>
);
}// Sharp-based image processing pipeline for user uploads
import sharp from "sharp";
interface ImageVariant {
width: number;
suffix: string;
quality: number;
}
const variants: ImageVariant[] = [
{ width: 320, suffix: "sm", quality: 80 },
{ width: 640, suffix: "md", quality: 80 },
{ width: 1280, suffix: "lg", quality: 85 },
{ width: 1920, suffix: "xl", quality: 85 },
];
async function processUploadedImage(
buffer: Buffer,
filename: string
): Promise<string[]> {
const urls: string[] = [];
for (const variant of variants) {
// Generate WebP (best compression)
const webp = await sharp(buffer)
.resize(variant.width, null, { withoutEnlargement: true })
.webp({ quality: variant.quality })
.toBuffer();
// Generate AVIF (even better compression, slower to encode)
const avif = await sharp(buffer)
.resize(variant.width, null, { withoutEnlargement: true })
.avif({ quality: variant.quality - 10 })
.toBuffer();
const webpUrl = await uploadToStorage(webp, `${filename}-${variant.suffix}.webp`);
const avifUrl = await uploadToStorage(avif, `${filename}-${variant.suffix}.avif`);
urls.push(webpUrl, avifUrl);
}
return urls;
}sizespriority// HTTP cache headers for different asset types
// next.config.js
module.exports = {
async headers() {
return [
// Static assets with content hashes - cache forever
{
source: "/_next/static/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
// API responses - short cache with revalidation
{
source: "/api/:path*",
headers: [
{
key: "Cache-Control",
value: "public, s-maxage=60, stale-while-revalidate=300",
},
],
},
// HTML pages - always revalidate
{
source: "/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=0, must-revalidate",
},
],
},
];
},
};// Server-side caching with Redis
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
interface CacheOptions {
ttlSeconds: number;
staleSeconds?: number; // Serve stale while revalidating
}
async function cached<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions
): Promise<T> {
const cached = await redis.get(key);
if (cached) {
const { data, expiry } = JSON.parse(cached);
const isStale = Date.now() > expiry;
if (!isStale) {
return data as T;
}
// Stale-while-revalidate: return stale data, refresh in background
if (options.staleSeconds) {
const staleDeadline = expiry + options.staleSeconds * 1000;
if (Date.now() < staleDeadline) {
// Revalidate in background (fire-and-forget)
refreshCache(key, fetcher, options).catch(console.error);
return data as T;
}
}
}
return refreshCache(key, fetcher, options);
}
async function refreshCache<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions
): Promise<T> {
const data = await fetcher();
const expiry = Date.now() + options.ttlSeconds * 1000;
const totalTtl = options.ttlSeconds + (options.staleSeconds ?? 0);
await redis.setex(key, totalTtl, JSON.stringify({ data, expiry }));
return data;
}
// Usage
const products = await cached(
`products:category:${categoryId}`,
() => db.products.findMany({ where: { categoryId } }),
{ ttlSeconds: 300, staleSeconds: 600 }
);stale-while-revalidate// load-test.js - k6 load test script
import http from "k6/http";
import { check, sleep } from "k6";
import { Rate, Trend } from "k6/metrics";
const errorRate = new Rate("errors");
const apiDuration = new Trend("api_duration", true);
export const options = {
stages: [
{ duration: "2m", target: 50 }, // Ramp up
{ duration: "5m", target: 50 }, // Steady state
{ duration: "2m", target: 200 }, // Spike
{ duration: "5m", target: 200 }, // Sustained spike
{ duration: "2m", target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ["p(95)<500", "p(99)<1000"], // 95th < 500ms, 99th < 1s
errors: ["rate<0.01"], // Error rate < 1%
api_duration: ["avg<200"], // Average API < 200ms
},
};
const BASE_URL = __ENV.BASE_URL || "http://localhost:3000";
export default function () {
// Simulate realistic user journey
const homeRes = http.get(`${BASE_URL}/`);
check(homeRes, {
"home status 200": (r) => r.status === 200,
"home load < 2s": (r) => r.timings.duration < 2000,
});
errorRate.add(homeRes.status >= 400);
sleep(1);
// API call
const apiRes = http.get(`${BASE_URL}/api/products?category=electronics`, {
headers: { "Content-Type": "application/json" },
});
check(apiRes, {
"api status 200": (r) => r.status === 200,
"api has results": (r) => JSON.parse(r.body).length > 0,
});
apiDuration.add(apiRes.timings.duration);
errorRate.add(apiRes.status >= 400);
sleep(Math.random() * 3); // Random think time
}# Run load test
k6 run load-test.js
# Run with custom base URL
k6 run -e BASE_URL=https://staging.example.com load-test.js
# Output results to JSON for analysis
k6 run --out json=results.json load-test.js| Metric | Good | Needs Work | Poor |
|---|---|---|---|
| LCP | < 2.5s | 2.5s - 4.0s | > 4.0s |
| INP | < 200ms | 200ms - 500ms | > 500ms |
| CLS | < 0.1 | 0.1 - 0.25 | > 0.25 |
| FCP | < 1.8s | 1.8s - 3.0s | > 3.0s |
| TTFB | < 800ms | 800ms - 1800ms | > 1800ms |
| Total JS | < 200kb gz | 200-400kb gz | > 400kb gz |
| Total page weight | < 1MB | 1-3MB | > 3MB |
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Optimizing without measuring | Wastes effort on non-bottlenecks | Profile first, then optimize measured hotspots |
| Loading all JS upfront | Blocks interactivity for unused code | Code split by route, lazy load heavy components |
Images without | Causes layout shift (CLS) | Always specify dimensions or use aspect-ratio |
| Every request hits origin server | Use |
| Loading web fonts synchronously | Blocks text rendering (FOIT) | Use |
Third-party scripts in | Blocks page rendering | Load with |
| N+1 database queries | Latency multiplies with data size | Use eager loading (Prisma |
priorityfetchpriority="high"font-display: swapmonitoring-observabilityserverless-developmentdocs/reference/checklists/ui-visual-changes.mddocs/reference/stacks/react-typescript.md