Core Web Vitals
Core Web Vitals
Measuring Web Vitals
Core Web Vitals are a set of standardized metrics Google uses to evaluate real-world user experience. They measure loading performance (LCP), interactivity (INP), and visual stability (CLS). Collecting these metrics in production is essential for detecting regressions before they impact SEO rankings.
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/metrics', body);
} else {
fetch('/api/metrics', { body, method: 'POST', keepalive: true });
}
}
onLCP(sendToAnalytics); // Largest Contentful Paint
onINP(sendToAnalytics); // Interaction to Next Paint
onCLS(sendToAnalytics); // Cumulative Layout Shift
LCP Optimization
Largest Contentful Paint measures how long it takes for the largest visible element (usually a hero image or heading) to render. Target under 2.5 seconds. Common bottlenecks include unoptimized images, render-blocking scripts, slow server response times, and late-discovered resources.
<!-- Preload hero image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- Use responsive images -->
<img
src="/hero-800.webp"
srcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Hero image"
fetchpriority="high"
decoding="async"
width="800" height="600"
>
<!-- Preconnect to external origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com">
CLS Prevention
Cumulative Layout Shift measures unexpected movement of visible elements. A good CLS score is under 0.1. Layout shifts are caused by images without dimensions, dynamically injected content, web fonts causing text reflow, and ads or embeds loading late.
/* Always set dimensions for images and videos */
img, video {
aspect-ratio: 16/9;
width: 100%;
height: auto;
}
/* Reserve space for ads/embeds */
.ad-container {
min-height: 250px;
background: #f0f0f0;
}
/* Avoid layout shifts from fonts */
@font-face {
font-family: 'CustomFont';
font-display: swap; /* fallback text shows immediately */
src: url('/fonts/custom.woff2') format('woff2');
}
/* Use CSS contain for isolated components */
.card {
contain: layout style; /* layout shifts don't propagate */
}
INP Optimization
Interaction to Next Paint measures responsiveness by tracking the latency of all interactions (clicks, taps, key presses) throughout the page lifecycle. Target under 200ms. Long tasks on the main thread are the primary cause of poor INP scores.
// Break long tasks into smaller chunks using scheduler API
async function processLargeDataset(items) {
const chunkSize = 100;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
processChunk(chunk);
// Yield to the main thread between chunks
await new Promise(resolve => {
if ('scheduler' in window && 'yield' in scheduler) {
scheduler.yield().then(resolve);
} else {
setTimeout(resolve, 0);
}
});
}
}
// Use requestIdleCallback for non-critical work
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0) {
const task = taskQueue.shift();
if (task) task();
}
});
// Move event handlers off main thread with Web Workers
const worker = new Worker('/analytics-worker.js');
navigator.sendBeacon = undefined; // fallback
worker.postMessage({ type: 'track', event: 'click', timestamp: Date.now() });
Web Vitals Thresholds
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP | <= 2.5s | 2.5s - 4.0s | > 4.0s |
| INP | <= 200ms | 200ms - 500ms | > 500ms |
| CLS | <= 0.1 | 0.1 - 0.25 | > 0.25 |
Bundle Optimization
Bundle Optimization
Code Splitting with React.lazy
Code splitting breaks your JavaScript bundle into smaller chunks that load on demand. Instead of downloading the entire application upfront, users only download the code needed for the current page. This dramatically reduces initial load time for large applications.
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
// Route-level code splitting with retry logic
const Dashboard = lazy(() =>
import('./pages/Dashboard').catch(() => import('./pages/DashboardFallback'))
);
Tree Shaking Tips
Tree shaking eliminates dead code by analyzing ES module import/export statements. It only works with static imports—dynamic imports cannot be tree-shaken. The key is to avoid pulling in entire libraries when you only need a single function.
// BAD: imports entire library (~70KB)
import _ from 'lodash';
_.debounce(fn, 300);
// GOOD: import specific function (~1KB)
import debounce from 'lodash/debounce';
debounce(fn, 300);
// Even better: use native alternatives (0KB)
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
// BAD: barrel imports prevent tree shaking
import { Button, Card, Modal } from './components';
// GOOD: direct imports allow tree shaking
import { Button } from './components/Button';
import { Card } from './components/Card';
// Use sideEffects flag in package.json to enable aggressive shaking
// package.json: { "sideEffects": ["*.css"] }
Vite Configuration for Production
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [react(), visualizer()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown'],
charts: ['recharts', 'd3-scale'],
},
},
},
target: 'esnext',
minify: 'terser',
terserOptions: {
compress: { drop_console: true, drop_debugger: true },
format: { comments: false },
},
cssCodeSplit: true,
sourcemap: false, // disable in production
},
optimizeDeps: {
include: ['react', 'react-dom'],
},
});
Bundle Analysis and Monitoring
# Visualize bundle composition
npx vite-bundle-visualizer
# Check bundle size against budget
npx size-limit
# Analyze what changed between builds
npx source-map-explorer 'dist/assets/*.js'
// .size-limit.json
[
{ "path": "dist/assets/vendor.*.js", "limit": "80 KB" },
{ "path": "dist/assets/app.*.js", "limit": "40 KB" },
{ "path": "dist/assets/*.css", "limit": "15 KB" }
]
Caching Strategies
Caching Strategies
HTTP Cache Headers
Proper cache headers tell browsers and CDNs how to store and reuse responses. Static assets with content-hashed filenames can be cached indefinitely, while API responses need shorter TTLs with revalidation strategies.
// Express.js cache configuration
const express = require('express');
const app = express();
// Static assets (immutable, long cache) — filenames include content hash
app.use('/assets', express.static('dist/assets', {
maxAge: '1y',
immutable: true,
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
}
},
}));
// API responses with stale-while-revalidate
app.get('/api/posts', (req, res) => {
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.json(data);
});
// Private user data — never cache
app.get('/api/me', (req, res) => {
res.set('Cache-Control', 'private, no-cache, must-revalidate');
res.json(user);
});
// Conditional requests with ETags
app.get('/api/resource/:id', (req, res) => {
const etag = generateETag(resource);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.json(resource);
});
Service Worker Caching
Service workers intercept network requests and serve cached responses when available. They enable offline functionality and dramatically speed up repeat visits by eliminating network round trips for previously fetched resources.
// sw.js — Workbox-style service worker
const CACHE_NAME = 'app-v2';
const STATIC_ASSETS = ['/', '/index.html', '/styles.css', '/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// Network-first for API calls
if (request.url.includes('/api/')) {
event.respondWith(
fetch(request)
.then(response => {
const clone = response.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(request, clone));
return response;
})
.catch(() => caches.match(request))
);
return;
}
// Cache-first for static assets
event.respondWith(
caches.match(request)
.then(cached => cached || fetch(request))
);
});
Redis Application Cache
Redis provides in-memory caching for database queries, API responses, and computed results. The cache-aside pattern is most common: check cache first, fall back to database on miss, then store the result.
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function cachedQuery<T>(key: string, ttl: number, queryFn: () => Promise<T>): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await queryFn();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
// Usage with cache stampede prevention
const posts = await cachedQuery('posts:popular', 300, async () => {
const result = await db.query(
'SELECT * FROM posts WHERE created_at > NOW() - INTERVAL 7 DAY ORDER BY view_count DESC LIMIT 10'
);
return result.rows;
});
// Cache invalidation on data update
async function updatePost(id: string, data: PostUpdate) {
await db.query('UPDATE posts SET $1 WHERE id = $2', [data, id]);
await redis.del('posts:popular');
await redis.del(`post:${id}`);
}
// Cache warming on startup
async function warmCache() {
await cachedQuery('posts:popular', 300, () => db.query('SELECT * FROM posts ORDER BY view_count DESC LIMIT 10'));
await cachedQuery('categories:all', 3600, () => db.query('SELECT * FROM categories'));
}
CDN Configuration
Content Delivery Networks cache static assets at edge locations worldwide, reducing latency for users far from your origin server. Proper cache invalidation and versioning strategies prevent stale content.
# Nginx CDN-friendly configuration
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
gzip on;
gzip_types text/css application/javascript image/svg+xml;
}
location /api/ {
add_header Cache-Control "no-store";
add_header X-Cache-Status $upstream_cache_status;
}
Server-Side Performance
Server-Side Performance
Database Query Optimization
N+1 query problems occur when an ORM executes a separate query for each related record instead of batching them. DataLoader solves this by collecting individual load requests within a single event loop tick and executing them as a batched query.
import DataLoader from 'dataloader';
// Before: N+1 queries — 1 query for posts + N queries for authors
const posts = await Post.findAll();
for (const post of posts) {
post.author = await User.findById(post.authorId); // N separate queries!
}
// After: DataLoader batches into 1 query
const userLoader = new DataLoader(async (ids: string[]) => {
const users = await User.findAll({ where: { id: ids } });
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || new Error(`User ${id} not found`));
});
// After: Sequelize eager loading (1 JOIN query)
const posts = await Post.findAll({
include: [{ model: User, as: 'author', attributes: ['id', 'name', 'avatar'] }],
});
// Index design for common queries
// CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
// CREATE INDEX idx_posts_author_id ON posts(author_id);
// CREATE INDEX idx_posts_status_created ON posts(status, created_at DESC);
// Use EXPLAIN ANALYZE to verify query plans
// EXPLAIN ANALYZE SELECT * FROM posts WHERE status = 'published' ORDER BY created_at DESC LIMIT 10;
Response Compression
Gzip or Brotli compression reduces response sizes by 60-80%, significantly improving transfer times especially on mobile networks. Compression is most effective for text-based responses (HTML, CSS, JS, JSON).
import compression from 'compression';
import brotli from 'brotli';
// Express compression middleware
app.use(compression({
level: 6,
threshold: 1024, // only compress responses > 1KB
filter: (req, res) => {
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
},
}));
// Brotli for static assets (better compression ratio)
app.use('/assets', (req, res, next) => {
if (req.headers['accept-encoding']?.includes('br')) {
const compressed = brotli.compress(Buffer.from(res.body), {
mode: 1,
quality: 4,
});
res.set('Content-Encoding', 'br');
res.send(compressed);
}
next();
});
// Pre-compress assets at build time
// In vite.config.ts:
// build: { brotliSize: true }
Connection Pooling
Database connections are expensive to establish. Connection pooling maintains a set of reusable connections, eliminating the overhead of creating and tearing down connections for every request.
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // max connections in pool
min: 5, // keep minimum connections warm
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
statement_timeout: 10000, // kill queries after 10s
});
// Monitor pool health
pool.on('error', (err) => console.error('Unexpected pool error:', err));
setInterval(() => {
console.log(
`Pool: total=${pool.totalCount} idle=${pool.idleCount} waiting=${pool.waitingCount}`
);
}, 30000);
// Graceful shutdown
process.on('SIGTERM', async () => {
await pool.end();
process.exit(0);
});
Process Memory Management
Node.js has a default heap limit that may need tuning for memory-intensive applications. Monitoring memory usage in production helps detect leaks before they cause crashes.
// Monitor memory usage every minute
setInterval(() => {
const used = process.memoryUsage();
console.log(
`Memory: RSS=${Math.round(used.rss / 1024 / 1024)}MB ` +
`Heap=${Math.round(used.heapUsed / 1024 / 1024)}/${Math.round(used.heapTotal / 1024 / 1024)}MB ` +
`External=${Math.round(used.external / 1024 / 1024)}MB`
);
}, 60000);
// Increase heap limit for large datasets
// node --max-old-space-size=4096 server.js
// Force garbage collection in development (never in production)
if (process.env.NODE_ENV === 'development' && global.gc) {
setInterval(() => global.gc(), 30000);
}
// Lazy initialization — don't allocate at startup
let dbConnection;
function getDb() {
if (!dbConnection) {
dbConnection = new Pool({ connectionString: process.env.DATABASE_URL });
}
return dbConnection;
}
Quiz
1. Which metric measures the time from when a user clicks a button to when the page visually responds to that interaction?
2. Why do barrel exports (index.ts files that re-export everything) hurt tree shaking?
3. What is the primary benefit of the stale-while-revalidate cache strategy?
Flashcards
Question
What is Performance Optimization?
Click to reveal answer
Answer
Performance Optimization covers important concepts and best practices.
Question
What is Performance Optimization?
Click to reveal answer
Answer
Performance Optimization covers important concepts and best practices.
Question
What is Performance Optimization?
Click to reveal answer
Answer
Performance Optimization covers important concepts and best practices.
Revision Notes
Key Takeaways
- 1. Core Web Vitals are LCP (loading, target <2.5s), INP (interactivity, target <200ms), and CLS (visual stability, target <0.1)
- 2. Preload critical resources with <link rel="preload"> and use fetchpriority="high" for above-the-fold images
- 3. Code splitting via React.lazy and route-level imports reduces initial bundle size by 40-60%
- 4. Always set explicit width/height on images to prevent CLS; use aspect-ratio CSS for responsive media
- 5. HTTP cache headers: immutable + long max-age for static assets, stale-while-revalidate for API responses
- 6. Service workers enable offline-first apps with cache-first for static assets and network-first for API calls
- 7. DataLoader eliminates N+1 query problems by batching database requests within a single event loop tick
- 8. Connection pooling prevents the overhead of creating new database connections per request
Interview Tips
- • Walk through your approach to diagnosing a slow LCP score: check server TTFB, render-blocking resources, image optimization, and font loading
- • Explain the trade-offs between cache-first, network-first, and stale-while-revalidate strategies with real use cases
- • Describe how you would implement a DataLoader for a GraphQL API that needs to resolve nested relationships efficiently
- • Compare code splitting approaches: route-level vs component-level vs library-level splitting and when each is appropriate
- • Discuss how you would monitor Core Web Vitals in production and set up alerts for regressions above the good thresholds
Cheat Sheet
LCP: preload images, preconnect origins, optimize fonts, reduce server TTFB
INP: break long tasks with scheduler.yield(), use Web Workers for heavy computation
CLS: set explicit dimensions on all media, use font-display: swap, reserve ad space
Code Splitting: React.lazy() + Suspense for route-level; direct imports for tree shaking
Caching: static assets → immutable 1y; API → stale-while-revalidate 60/300; user data → no-cache
Service Worker: cache-first for assets, network-first for API, cleanup old caches in activate
DB Performance: DataLoader for N+1, EXPLAIN ANALYZE to verify indexes, connection pooling (max: 20)
Compression: gzip level 6 threshold 1KB, Brotli for static assets, pre-compress at build time