Web Performance Interview Guide: Core Web Vitals and Optimization

·13 min read
web-performancecore-web-vitalsfrontendlighthouseoptimizationinterview-preparation

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

MetricMeasuresGoodNeeds WorkPoor
LCP (Largest Contentful Paint)Loading speed< 2.5s2.5-4s> 4s
INP (Interaction to Next Paint)Interactivity< 200ms200-500ms> 500ms
CLS (Cumulative Layout Shift)Visual stability< 0.10.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 build

Look 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:

  1. Minimize critical resources - Inline critical CSS, defer non-critical
  2. Reduce critical bytes - Minify, compress, remove unused code
  3. 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

OperationTriggered ByCost
Reflow (Layout)Size, position, or DOM structure changesHigh
RepaintColor, visibility, background changesMedium
Compositetransform, opacityLow

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

FormatBest ForBrowser Support
WebPGeneral use, photos97%+
AVIFMaximum compression85%+
SVGIcons, illustrations100%
PNGTransparency needed, fallback100%
<!-- 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:

  1. Parses sizes to determine display width
  2. Considers device pixel ratio (DPR)
  3. 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:

ValueBehavior
swapShow fallback immediately, swap when loaded
blockBrief invisible text, then show custom font
fallbackVery brief block, then fallback, late swap ignored
optionalBrowser 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:

DirectiveMeaning
publicCDN and browser can cache
privateOnly browser can cache (user-specific data)
max-age=NCache for N seconds
immutableNever revalidate (use with versioned URLs)
no-cacheCache but revalidate before use
no-storeDon'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

PatternUse Case
Cache FirstStatic assets, fonts, images
Network FirstAPI data, frequently updated content
Stale While RevalidateBalance between freshness and speed
Cache OnlyOffline-first apps, installed assets
Network OnlyReal-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 tab

Key Lighthouse metrics:

MetricWhat It Measures
FCPFirst Contentful Paint
LCPLargest Contentful Paint
TBTTotal Blocking Time (correlates with INP)
CLSCumulative Layout Shift
Speed IndexHow quickly content is visually populated

Chrome DevTools Performance

The Performance panel shows exactly what happens during page load:

  1. Record page load or interaction
  2. Analyze the flame chart for long tasks
  3. Identify layout thrashing, forced reflows
  4. 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


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.

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides