Performance Triage
You already know how to read a latency histogram and identify which percentile matters. Frontend performance metrics are the same discipline applied to a different pipeline — one that ends at pixels on a screen rather than bytes on a wire.
The mistake backend engineers make is optimizing without measuring. They reach for React.memo and useMemo the moment something feels slow. That is premature optimization. Triage first, then fix the right thing.
Web Vitals: Your SLOs for the Browser
Web Vitals are Google's standardized metrics for user-perceived page performance. Treat them as SLOs — thresholds below which the user experience is considered acceptable.
| Metric | What it measures | Good | Needs Work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Load — when is the main content visible? | < 2.5s | 2.5–4s | > 4s |
| INP (Interaction to Next Paint) | Responsiveness — how quickly does a click respond? | < 200ms | 200–500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Stability — does the page jump around? | < 0.1 | 0.1–0.25 | > 0.25 |
Measure in the field (Chrome User Experience Report, web-vitals library) and in the lab (Lighthouse, browser DevTools). Lab metrics do not always predict field metrics — network and device conditions matter.
Lighthouse: The Starting Point
Lighthouse runs automated audits and gives you a prioritized list of issues. Run it from Chrome DevTools > Lighthouse, or from the CLI for CI integration:
npx lighthouse https://your-app.com \
--output=json \
--output-path=./lighthouse-report.json \
--chrome-flags="--headless"The most actionable Lighthouse output is the Opportunities and Diagnostics sections. Do not optimize the Passed Audits — focus on opportunities sorted by estimated savings.
Common Lighthouse findings for React apps and their fixes:
| Finding | Cause | Fix |
|---|---|---|
| Render-blocking resources | Synchronous <script> in <head> |
Use async / defer, or move to bottom |
| Unused JavaScript | Large bundle, no code splitting | Dynamic imports, route-level splitting |
| LCP element not prioritized | Hero image not preloaded | <link rel="preload"> or fetchpriority="high" |
| Long main thread tasks | Synchronous heavy JS | Offload to Web Worker or break into microtasks |
Bundle Analysis: Finding the Dead Weight
Bundle analysis is the frontend equivalent of profiling a slow SQL query — you need to see where the bytes are before you can eliminate them.
# Next.js
ANALYZE=true next build
# Vite
npx vite-bundle-visualizer
# webpack
npx webpack-bundle-analyzer stats.jsonWhat to look for in the bundle treemap:
Fixing large bundles:
// Dynamic import for heavy components — only loads when rendered
const RichTextEditor = dynamic(() => import("./RichTextEditor"), {
loading: () => <div className="skeleton h-40" />,
});
// Dynamic import for heavy libraries
async function generatePDF(data: ReportData) {
const { jsPDF } = await import("jspdf"); // loads only when called
const doc = new jsPDF();
doc.text(data.title, 10, 10);
doc.save("report.pdf");
}
// Replace heavy libraries with lighter alternatives
// Before: import { format } from "date-fns"; // 12KB
// After: use Intl.DateTimeFormat — zero bundle cost
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric", month: "long", day: "numeric",
}).format(date);
}React-Specific Performance: When to Actually Optimize
React re-renders are not inherently expensive. Unnecessary re-renders are only a problem when they cause visible jank. Profile before memoizing.
Use the React DevTools Profiler to identify which components render on what interaction, and how long each render takes.
// useMemo: memoize expensive computations, not cheap ones
function ProductSearch({ products, query }: Props) {
// Only memoize if this computation is measurably slow
const filtered = useMemo(
() => products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
),
[products, query]
);
return <ProductGrid products={filtered} />;
}
// React.memo: prevent re-render when parent re-renders but props unchanged
const ProductCard = React.memo(function ProductCard({ product }: { product: Product }) {
return <div>{product.name}</div>;
});
// useCallback: stabilize function references passed to memoized children
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("clicked");
}, []); // stable reference — ProductCard does not re-render on count change
return (
<>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<ProductCard product={...} onSelect={handleClick} />
</>
);
}The rule: reach for useMemo and useCallback when the React Profiler shows a specific component taking > 16ms to render (1 frame at 60fps), not before.
Image Optimization
Images are the most common LCP bottleneck. Use the framework's image component which handles lazy loading, responsive sizing, and format optimization:
// Next.js — automatic WebP/AVIF conversion, responsive srcset, lazy load by default
import Image from "next/image";
function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // fetchpriority="high" for LCP element — do not lazy-load the hero
sizes="(max-width: 768px) 100vw, 50vw"
/>
);
}Always set explicit width and height on images to prevent CLS — the browser reserves the space before the image loads.
Key Takeaways
- Treat Web Vitals (LCP, INP, CLS) as SLOs with explicit thresholds — measure field data, not just lab data, to understand real user experience.
- Triage with Lighthouse before optimizing — its Opportunities section gives you estimated savings per fix, letting you prioritize high-impact work.
- Bundle analysis is mandatory before shipping a production app; look for large vendor libraries that can be replaced, tree-shaken, or dynamically imported.
- Dynamic imports split your bundle at route or component boundaries — heavy libraries should load only when the feature that needs them is activated.
- Use
useMemo,useCallback, andReact.memoonly when the React DevTools Profiler identifies a specific slow component, not preemptively. - Set explicit dimensions on images and use
priorityon the LCP element to prevent layout shift and ensure the hero image loads with high priority.