Web performance isn't optional anymore. Google uses Core Web Vitals as a ranking factor. Users abandon slow sites. And interviewers expect frontend developers to understand optimization beyond "it should load fast."
This guide covers the performance concepts that come up in frontend interviews—from Core Web Vitals to caching strategies to framework-specific patterns.
Core Web Vitals
Google's Core Web Vitals are three metrics that measure real user experience. They affect SEO rankings and are the foundation of modern performance discussions.
The Three Metrics
| Metric | Measures | Good | Needs Work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading speed | < 2.5s | 2.5-4s | > 4s |
| INP (Interaction to Next Paint) | Interactivity | < 200ms | 200-500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Visual stability | < 0.1 | 0.1-0.25 | > 0.25 |
LCP: Largest Contentful Paint
LCP measures when the largest visible element finishes rendering—usually a hero image, heading, or text block.
Common causes of poor LCP:
- Slow server response time
- Render-blocking JavaScript and CSS
- Slow resource load times (images, fonts)
- Client-side rendering delays
How to improve LCP:
<!-- Preload critical resources -->
<link rel="preload" href="/hero-image.webp" as="image">
<link rel="preload" href="/critical-font.woff2" as="font" crossorigin>
<!-- Inline critical CSS -->
<style>
.hero { /* critical styles */ }
</style>
<!-- Defer non-critical CSS -->
<link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'">INP: Interaction to Next Paint
INP replaced FID (First Input Delay) in 2024. It measures the delay between any user interaction and the browser's visual response—not just the first interaction.
Common causes of poor INP:
- Long-running JavaScript blocking the main thread
- Too many event listeners
- Heavy re-renders on interaction
- Synchronous operations during handlers
How to improve INP:
// Bad: Blocking the main thread
button.addEventListener('click', () => {
const result = heavyComputation(); // Blocks for 500ms
updateUI(result);
});
// Good: Break up work with scheduler
button.addEventListener('click', async () => {
// Show immediate feedback
button.disabled = true;
// Yield to browser between chunks
const result = await yieldingComputation();
updateUI(result);
});
// Using scheduler.yield() (modern browsers)
async function yieldingComputation() {
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += expensiveStep(i);
if (i % 10000 === 0) {
await scheduler.yield(); // Let browser handle events
}
}
return result;
}CLS: Cumulative Layout Shift
CLS measures unexpected layout shifts—when visible elements move without user interaction. Nothing frustrates users more than clicking a button that shifts right before they tap.
Common causes of poor CLS:
- Images without dimensions
- Ads, embeds, or iframes without reserved space
- Web fonts causing text reflow (FOIT/FOUT)
- Dynamically injected content
How to prevent layout shifts:
<!-- Always specify image dimensions -->
<img src="photo.jpg" width="800" height="600" alt="..." />
<!-- Or use aspect-ratio in CSS -->
<style>
.video-container {
aspect-ratio: 16 / 9;
width: 100%;
}
</style>
<!-- Reserve space for dynamic content -->
<div class="ad-slot" style="min-height: 250px;">
<!-- Ad loads here -->
</div>Font loading without layout shift:
/* Use font-display: swap with size-adjust */
@font-face {
font-family: 'CustomFont';
src: url('/font.woff2') format('woff2');
font-display: swap;
size-adjust: 105%; /* Match fallback font metrics */
}JavaScript Performance
JavaScript is usually the biggest performance bottleneck in modern web apps. Understanding bundle optimization is essential for frontend interviews.
Bundle Size Reduction
1. Code Splitting
Split your bundle into chunks loaded on demand:
// Route-based splitting (React)
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}// Route-based splitting (Angular)
const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component')
.then(m => m.DashboardComponent)
}
];2. Tree Shaking
Tree shaking removes unused code. It works with ES modules, not CommonJS:
// Bad: Imports entire library
import _ from 'lodash';
_.debounce(fn, 300);
// Good: Imports only what's used (tree-shakeable)
import debounce from 'lodash/debounce';
debounce(fn, 300);
// Even better: Use native or lighter alternatives
function debounce(fn, ms) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), ms);
};
}3. Analyze Your Bundle
# Webpack
npx webpack-bundle-analyzer stats.json
# Vite
npx vite-bundle-visualizer
# Next.js
ANALYZE=true npm run buildLook for:
- Duplicate dependencies
- Unused code making it into the bundle
- Large libraries that could be replaced
- Code that should be lazy-loaded
Dynamic Imports
Dynamic imports load code when needed, not upfront:
// Load heavy library only when used
async function generatePDF() {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF();
// Generate PDF...
}
// Conditional feature loading
if (user.hasAdvancedFeatures) {
const module = await import('./advanced-features');
module.init();
}Main Thread Optimization
The main thread handles JavaScript, layout, paint, and user input. Block it and your app feels frozen.
Techniques to keep the main thread responsive:
// 1. Web Workers for heavy computation
const worker = new Worker('/compute-worker.js');
worker.postMessage(data);
worker.onmessage = (e) => updateUI(e.data);
// 2. requestIdleCallback for non-urgent work
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
performTask(tasks.shift());
}
});
// 3. setTimeout to break up synchronous work
function processLargeArray(items, callback) {
const chunk = 100;
let index = 0;
function processChunk() {
const end = Math.min(index + chunk, items.length);
for (; index < end; index++) {
processItem(items[index]);
}
if (index < items.length) {
setTimeout(processChunk, 0); // Yield to browser
} else {
callback();
}
}
processChunk();
}Rendering Performance
Understanding how browsers render helps you avoid performance pitfalls.
The Critical Rendering Path
HTML → DOM
↘
Render Tree → Layout → Paint → Composite
↗
CSS → CSSOM
Optimizing the critical path:
- Minimize critical resources - Inline critical CSS, defer non-critical
- Reduce critical bytes - Minify, compress, remove unused code
- Shorten critical path length - Reduce round trips, use HTTP/2
<!-- Optimal resource loading order -->
<head>
<!-- Critical CSS inlined -->
<style>/* Above-the-fold styles */</style>
<!-- Preload critical assets -->
<link rel="preload" href="/critical.js" as="script">
<!-- Defer non-critical CSS -->
<link rel="preload" href="/styles.css" as="style"
onload="this.rel='stylesheet'">
<!-- Defer JavaScript -->
<script src="/app.js" defer></script>
</head>Layout Thrashing
Layout thrashing happens when you read layout properties, then write, then read again—forcing the browser to recalculate layout repeatedly.
// Bad: Forces layout recalculation on each iteration
elements.forEach(el => {
const height = el.offsetHeight; // Read (forces layout)
el.style.height = height + 10 + 'px'; // Write (invalidates layout)
});
// Good: Batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // All reads first
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; // All writes after
});
// Better: Use CSS where possible
elements.forEach(el => {
el.style.height = 'calc(100% + 10px)';
});Reflows vs Repaints
| Operation | Triggered By | Cost |
|---|---|---|
| Reflow (Layout) | Size, position, or DOM structure changes | High |
| Repaint | Color, visibility, background changes | Medium |
| Composite | transform, opacity | Low |
Prefer compositor-only properties for animations:
/* Bad: Triggers reflow */
.animate {
transition: left 0.3s, top 0.3s, width 0.3s;
}
/* Good: Compositor-only, runs on GPU */
.animate {
transition: transform 0.3s, opacity 0.3s;
}
/* Move element without triggering layout */
.moved {
transform: translateX(100px);
}Asset Optimization
Images typically account for most of a page's bytes. Optimizing them has outsized impact.
Modern Image Formats
| Format | Best For | Browser Support |
|---|---|---|
| WebP | General use, photos | 97%+ |
| AVIF | Maximum compression | 85%+ |
| SVG | Icons, illustrations | 100% |
| PNG | Transparency needed, fallback | 100% |
<!-- Serve modern formats with fallbacks -->
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Description" width="800" height="600" />
</picture>Responsive Images
<!-- Different sizes for different viewports -->
<img
src="photo-800.jpg"
srcset="
photo-400.jpg 400w,
photo-800.jpg 800w,
photo-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw, 800px"
alt="Description"
/>How the browser chooses:
- Parses
sizesto determine display width - Considers device pixel ratio (DPR)
- Selects smallest image that covers the need
Lazy Loading
<!-- Native lazy loading -->
<img src="photo.jpg" loading="lazy" alt="..." />
<!-- For more control, use Intersection Observer -->// Intersection Observer for lazy loading
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: '100px' }); // Load 100px before visible
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});Font Optimization
/* 1. Use font-display to control loading behavior */
@font-face {
font-family: 'CustomFont';
src: url('/font.woff2') format('woff2');
font-display: swap; /* Show fallback, swap when loaded */
}
/* 2. Preload critical fonts */<link rel="preload" href="/font.woff2" as="font" type="font/woff2" crossorigin>font-display values:
| Value | Behavior |
|---|---|
swap | Show fallback immediately, swap when loaded |
block | Brief invisible text, then show custom font |
fallback | Very brief block, then fallback, late swap ignored |
optional | Browser decides based on connection speed |
Caching Strategies
Effective caching dramatically improves repeat visit performance.
Browser Cache
# Cache-Control header strategies
# Static assets (versioned filenames)
Cache-Control: public, max-age=31536000, immutable
# HTML (always revalidate)
Cache-Control: no-cache
# API responses (short cache)
Cache-Control: private, max-age=60
Cache-Control directives:
| Directive | Meaning |
|---|---|
public | CDN and browser can cache |
private | Only browser can cache (user-specific data) |
max-age=N | Cache for N seconds |
immutable | Never revalidate (use with versioned URLs) |
no-cache | Cache but revalidate before use |
no-store | Don't cache at all |
Service Worker Caching
// sw.js - Cache-first strategy for static assets
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'image' ||
event.request.destination === 'style' ||
event.request.destination === 'script') {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
const clone = response.clone();
caches.open('static-v1').then(cache => {
cache.put(event.request, clone);
});
return response;
});
})
);
}
});
// Network-first strategy for API calls
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open('api-v1').then(cache => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => caches.match(event.request))
);
}
});Caching Patterns Summary
| Pattern | Use Case |
|---|---|
| Cache First | Static assets, fonts, images |
| Network First | API data, frequently updated content |
| Stale While Revalidate | Balance between freshness and speed |
| Cache Only | Offline-first apps, installed assets |
| Network Only | Real-time data, auth requests |
Measuring Performance
You can't optimize what you don't measure. These tools and metrics guide optimization efforts.
Lighthouse
Lighthouse audits performance, accessibility, SEO, and best practices.
# Run from command line
npx lighthouse https://example.com --output=html
# Or use Chrome DevTools > Lighthouse tabKey Lighthouse metrics:
| Metric | What It Measures |
|---|---|
| FCP | First Contentful Paint |
| LCP | Largest Contentful Paint |
| TBT | Total Blocking Time (correlates with INP) |
| CLS | Cumulative Layout Shift |
| Speed Index | How quickly content is visually populated |
Chrome DevTools Performance
The Performance panel shows exactly what happens during page load:
- Record page load or interaction
- Analyze the flame chart for long tasks
- Identify layout thrashing, forced reflows
- Check the Frames section for jank (frames > 16ms)
Real User Monitoring (RUM)
Lab data (Lighthouse) shows potential. Field data (RUM) shows reality.
// Capture Core Web Vitals with web-vitals library
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics({ name, value, id }) {
// Send to your analytics endpoint
navigator.sendBeacon('/analytics', JSON.stringify({
metric: name,
value: value,
id: id
}));
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);Google's field data sources:
- Chrome User Experience Report (CrUX)
- PageSpeed Insights (shows both lab and field data)
- Search Console Core Web Vitals report
Framework-Specific Optimizations
Each framework has its own performance patterns.
React
// 1. Memoize expensive components
const ExpensiveList = React.memo(({ items }) => {
return items.map(item => <ListItem key={item.id} {...item} />);
});
// 2. useMemo for expensive calculations
const sortedItems = useMemo(() => {
return items.sort((a, b) => a.date - b.date);
}, [items]);
// 3. useCallback for stable function references
const handleClick = useCallback((id) => {
setSelected(id);
}, []);
// 4. Virtualize long lists
import { FixedSizeList } from 'react-window';
function VirtualList({ items }) {
return (
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={50}
>
{({ index, style }) => (
<div style={style}>{items[index].name}</div>
)}
</FixedSizeList>
);
}Angular
// 1. OnPush change detection
@Component({
selector: 'app-list',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent {
@Input() items: Item[];
}
// 2. trackBy for ngFor
<div *ngFor="let item of items; trackBy: trackById">
{{ item.name }}
</div>
trackById(index: number, item: Item): number {
return item.id;
}
// 3. Lazy load routes
const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.module')
.then(m => m.AdminModule)
}
];
// 4. Use Signals for fine-grained reactivity (Angular 16+)
count = signal(0);
doubled = computed(() => this.count() * 2);Next.js
// 1. Use Image component for automatic optimization
import Image from 'next/image';
<Image
src="/hero.jpg"
width={1200}
height={600}
priority // Preload above-fold images
placeholder="blur"
/>
// 2. Dynamic imports for code splitting
import dynamic from 'next/dynamic';
const DynamicChart = dynamic(() => import('./Chart'), {
loading: () => <ChartSkeleton />,
ssr: false // Don't render on server if client-only
});
// 3. Use React Server Components (default in App Router)
// Server Components don't add to client bundle
async function ProductList() {
const products = await db.products.findMany();
return products.map(p => <ProductCard key={p.id} product={p} />);
}
// 4. Streaming with Suspense
<Suspense fallback={<ProductsSkeleton />}>
<ProductList />
</Suspense>Quick Reference
Core Web Vitals targets:
- LCP < 2.5s
- INP < 200ms
- CLS < 0.1
Bundle optimization:
- Code split routes and heavy features
- Tree shake with ES modules
- Analyze bundle regularly
- Replace heavy dependencies
Image optimization:
- Use WebP/AVIF formats
- Implement responsive images
- Lazy load below-fold images
- Always set width/height
Caching:
- Immutable cache for versioned assets
- No-cache for HTML
- Service workers for offline/fast repeat loads
Measurement:
- Lighthouse for lab data
- RUM (web-vitals) for field data
- DevTools Performance for debugging
Related Articles
- Complete Frontend Developer Interview Guide - Full guide to frontend interviews
- Next.js Interview Guide - Next.js optimization and performance
- React Advanced Interview Guide - React performance patterns
- Angular Change Detection Interview Guide - Angular performance optimization
What's Next?
Performance optimization is an ongoing process, not a one-time fix. Start by measuring your current state with Lighthouse and field data. Focus on the biggest opportunities—usually images and JavaScript bundle size. Then iterate.
In interviews, demonstrate that you understand the "why" behind performance optimizations, not just the "how." Explain trade-offs. Show that you measure before and after changes.
