Optimizing Web Vitals
Your e-commerce store is ready for the holiday rush, but performance testing reveals critical problems: images loading slowly from a remote CDN, a promotional banner popping in and shifting content, and a completely blank sale page while waiting for slow backend queries. These issues directly impact Web Vitals scores - and conversion rates. Let’s use Sentry to identify and fix them.
Learning Objectives
Section titled “Learning Objectives”By the end of this module, you will:
- Understand how all 5 Web Vitals (LCP, CLS, FCP, TTFB, INP) impact e-commerce conversions
- Fix slow-loading images causing poor LCP scores and layout shifts
- Eliminate CLS from conditional banners and unoptimized images
- Resolve render-blocking page loads with skeleton screens
- Identify frontend render-blocking patterns causing poor FCP
- Measure improvements across all metrics using Sentry’s Web Vitals dashboard
Generate Sample Usage Data
Section titled “Generate Sample Usage Data”Before we start analyzing Web Vitals, let’s generate some sample user traffic to create realistic performance data:
pnpm generate:usageThis command simulates user interactions with your e-commerce app (browsing products, adding to cart, navigating between pages). Once complete, you’ll have real performance data in Sentry to analyze.
Understanding Core Web Vitals
Section titled “Understanding Core Web Vitals”In Module 1, you set up Sentry to automatically capture performance data. Now let’s understand what that data means and how to use it to optimize your site.
Sentry tracks five critical Web Vitals metrics that measure how users actually experience your site:
-
LCP (Largest Contentful Paint): How long until the main content loads
- Good: < 2.5s | Needs Improvement: 2.5-4s | Poor: > 4s
- Weight: 30% of Performance Score
-
CLS (Cumulative Layout Shift): How much content moves around while loading
- Good: < 0.1 | Needs Improvement: 0.1-0.25 | Poor: > 0.25
- Weight: 15% of Performance Score
-
FCP (First Contentful Paint): How quickly users see any content on the page
- Good: < 1.8s | Needs Improvement: 1.8-3s | Poor: > 3s
- Weight: 15% of Performance Score
-
TTFB (Time to First Byte): How quickly the server responds
- Good: < 800ms | Needs Improvement: 800-1800ms | Poor: > 1800ms
- Weight: 10% of Performance Score
-
INP (Interaction to Next Paint): How responsive your page is to user interactions
- Good: < 200ms | Needs Improvement: 200-500ms | Poor: > 500ms
- Weight: 30% of Performance Score
These metrics are combined into a Performance Score (0-100) that summarizes your app’s overall performance. In this module, we’ll focus on fixing two major frontend performance problems:
- Homepage (LCP + CLS): Slow-loading remote images and a late-appearing promo banner causing combined layout shift issues
- Sale Page (FCP): Completely blank page until backend returns data - a render-blocking pattern that prevents any content from painting
To learn more about Web Vitals, check out our documentation.
Issue #1: Homepage Performance Problems (LCP + CLS)
Section titled “Issue #1: Homepage Performance Problems (LCP + CLS)”Your homepage has multiple performance issues working together: slow-loading product images from a remote CDN (poor LCP) combined with a promotional banner that appears after authentication loads (poor CLS). Both affect the same page and compound each other.
Identifying the Problems
Section titled “Identifying the Problems”-
Navigate to the homepage
Open http://localhost:4173 and watch what happens:
- The hero section renders first
- Product cards appear with collapsed containers (no image yet)
- Images load slowly from GitHub, expanding the containers
- A promo banner suddenly pops in at the top (for logged-out users)
- Everything shifts down!
-
Check LCP and CLS in Sentry
- Go to your frontend project in Sentry
- Navigate to Insights → Frontend → Web Vitals
- Filter by page:
/(homepage) - You’ll see LCP scores in the “Poor” range (likely > 4s)
- Now check CLS for the same page
- CLS scores will be “Poor” (> 0.25) for unauthenticated users
The Root Cause
Section titled “The Root Cause”Looking at apps/web/src/components/ProductCard.tsx, you’ll see:
<div className="relative"> <img src={product.image} alt={product.name} className="w-full object-cover" /> <div className="absolute inset-0 bg-black/0 hover:bg-black/10 transition-opacity" /></div>The images are also loading from https://raw.githubusercontent.com/... (remote CDN) instead of being served locally or properly optimized.
Also, the promo banner is appearing after authentication is checked, which causes layout shifts.
export default function PromoBanner() { const { isAuthenticated, isLoading } = useAuth();
if (isLoading) { return null; // ❌ Nothing renders, then suddenly appears }
if (isAuthenticated) { return null; }
return ( <div className="bg-gradient-to-r from-red-500 to-orange-500 text-white py-3 px-4..."> 🎉 New Customer? Get 10% off your first order! Use code: WELCOME10 </div> );}The Fix: Optimize Images + Reserve Banner Space
Section titled “The Fix: Optimize Images + Reserve Banner Space”-
Add proper image sizing and aspect ratio
Open
apps/web/src/components/ProductCard.tsxand update:ProductCard.tsx <div className="relative"><imgsrc={product.image}alt={product.name}className="w-full object-cover"/><div className="relative aspect-square"><imgsrc={product.image}alt={product.name}width={300}height={300}loading="eager"decoding="async"className="w-full h-full object-cover"/><div className="absolute inset-0 bg-black/0 hover:bg-black/10 transition-opacity" /></div> -
Use local or optimized images
For the purposes of this workshop, we were using images from GitHub. In a more realistic scenario, you would use a CDN, but sometimes you may forget to optimize your images.
Make sure you’re serving images that are optimized for the expected experience. For responsive images, you should use the
srcsetattribute to serve the appropriate image for the screen size. Use formats like WebP and AVIF if supported by the browser and always make sure to have fallbacks for older browsers. -
Reserve space for the promo banner
Update
apps/web/src/components/PromoBanner.tsxto always reserve space:PromoBanner.tsx import { useAuth } from '../context/AuthContext';export default function PromoBanner() {const { isAuthenticated, isLoading } = useAuth();// ✅ Always render container to reserve spacereturn (<divclassName="banner-container"style={{ minHeight: isAuthenticated ? '0px' : '48px' }}>{!isLoading && !isAuthenticated && (<div className="bg-gradient-to-r from-red-500 to-orange-500 text-white py-3 px-4 text-center font-semibold shadow-md">🎉 New Customer? Get 10% off your first order! Use code: WELCOME10</div>)}</div>);}By always rendering a container with
minHeight: '48px', we prevent layout shift when the banner appears. For authenticated users and during loading, the container remains empty but preserves the vertical space. -
Test the improvements
- Reload the homepage (logged out)
- Images should load faster and not shift layout
- Banner area should not cause layout shifts
- Check Sentry → Web Vitals:
- LCP improves from > 4s to < 2.5s ✅
- CLS improves from > 0.25 to < 0.1 ✅
Issue #2: Render-Blocking Sale Page (FCP)
Section titled “Issue #2: Render-Blocking Sale Page (FCP)”Your “On Sale” page returns null during loading, showing a completely blank white screen until the API call completes. The entire page - including the header and navigation - doesn’t render until data arrives. This causes catastrophic FCP (First Contentful Paint) scores because React doesn’t paint anything while waiting for the backend.
Identifying the Problem
Section titled “Identifying the Problem”-
Navigate to the sale page
Go to http://localhost:4173/sale
Notice the completely blank white screen for 2-4 seconds? Not even the header loads. That’s blocking FCP entirely.
-
Check FCP in Sentry
- Navigate to Insights → Frontend → Web Vitals
- Filter by page:
/sale - FCP is likely in the “Poor” range (> 3s) - nothing paints until the API returns!
The Root Cause
Section titled “The Root Cause”Looking at apps/web/src/pages/Sale.tsx:
function Sale() { const [products, setProducts] = useState<SaleProduct[]>([]); const [loading, setLoading] = useState<boolean>(true); const [error, setError] = useState<string | null>(null);
const fetchSaleProducts = async () => { try { setLoading(true); const data = await productService.getSaleProducts(); setProducts(data); setError(null); } catch (err) { setError('Failed to load sale products. Please try again.'); } finally { setLoading(false); } };
useEffect(() => { fetchSaleProducts(); }, []);
// ❌ Nothing renders! Completely blank page! if (loading) { return null; }
if (error) { return <div>{/* Error UI */}</div>; }
return ( <> <Header /> <main>{/* Sale products */}</main> </> );}The page returns null during loading, meaning nothing renders at all - not even the HTML structure! The <Header /> component (with navigation) only renders after the API call completes. This creates a terrible user experience where visitors see a completely blank white page for 2-4 seconds.
The Fix: Skeleton Screen
Section titled “The Fix: Skeleton Screen”-
Render page structure immediately
Update
apps/web/src/pages/Sale.tsxto always render the Header and show a skeleton during loading:Sale.tsx import { useEffect, useState } from 'react';import { Link } from 'react-router-dom';import Header from '../components/Header';import { productService } from '../services/api';import { SaleProduct } from '../types';import * as Sentry from '@sentry/react';function Sale() {const [products, setProducts] = useState<SaleProduct[]>([]);const [loading, setLoading] = useState<boolean>(true);const [error, setError] = useState<string | null>(null);const fetchSaleProducts = async () => {try {setLoading(true);const data = await productService.getSaleProducts();setProducts(data);setError(null);} catch (err) {Sentry.captureException(err);setError('Failed to load sale products. Please try again.');} finally {setLoading(false);}};useEffect(() => {fetchSaleProducts();}, []);const calculateSavings = (original: string, sale: string) => {const originalPrice = parseFloat(original);const salePrice = parseFloat(sale);const savings = originalPrice - salePrice;const percentage = ((savings / originalPrice) * 100).toFixed(0);return { savings: savings.toFixed(2), percentage };};// ✅ Always render header and page structurereturn (<><Header /><main className="max-w-7xl mx-auto py-16 px-4"><div className="mb-12 text-center"><h2 className="text-4xl font-bold mb-4">Amazing Deals on Developer Tools</h2><p className="text-xl text-gray-600">Don't miss out on these incredible savings</p></div>{error ? (// Error state with retry<div className="text-center py-10"><h2 className="text-2xl font-bold mb-4">Error</h2><p className="text-red-500">{error}</p><buttononClick={fetchSaleProducts}className="mt-4 bg-black text-white px-4 py-2 rounded hover:bg-red-500">Try Again</button></div>) : loading ? (// ✅ Skeleton grid shows immediately<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">{Array.from({ length: 12 }).map((_, i) => (<divkey={i}className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse"><div className="bg-gray-300 h-64 w-full" /><div className="p-6 space-y-3"><div className="h-4 bg-gray-300 rounded w-3/4" /><div className="h-4 bg-gray-300 rounded w-1/2" /></div></div>))}</div>) : products.length === 0 ? (// Empty state<div className="text-center py-10"><h2 className="text-2xl font-bold mb-4">No Sale Items Available</h2><p className="text-gray-600">Check back soon for amazing deals!</p></div>) : (// Products loaded<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">{products.map((product) => {const { savings, percentage } = calculateSavings(product.originalPrice,product.salePrice);return (<Linkkey={product.id}to={`/product/${product.id}`}className="group"><div className="bg-white rounded-lg shadow-md overflow-hidden transition-transform duration-300 group-hover:scale-105 group-hover:shadow-xl border-2 border-red-500">{product.featured && (<div className="bg-red-500 text-white text-center py-1 font-bold text-sm">FEATURED DEAL</div>)}<div className="relative"><imgsrc={product.image ||'https://via.placeholder.com/300x200?text=Product'}alt={product.name}className="w-full h-64 object-cover"/><div className="absolute top-4 right-4 bg-red-500 text-white px-3 py-2 rounded-full font-bold text-lg">-{percentage}%</div></div><div className="p-6"><h3 className="text-xl font-semibold mb-2">{product.name}</h3><p className="text-gray-600 mb-4 line-clamp-2">{product.description}</p><div className="flex items-center justify-between"><div><p className="text-gray-500 line-through text-sm">${parseFloat(product.originalPrice).toFixed(2)}</p><p className="text-3xl font-bold text-red-500">${parseFloat(product.salePrice).toFixed(2)}</p><p className="text-green-600 font-semibold text-sm">Save ${savings}</p></div></div></div></div></Link>);})}</div>)}</main></>);}export default Sale;Key changes:
- Header always renders - Users see navigation immediately
- Skeleton appears instantly - No blank page while loading
- Error state includes Header - Consistent experience even on failure
- All states properly handled - Loading, error, empty, and success
- Sale-specific rendering preserved - Discount badges, featured banners, savings calculations all intact
-
Test the improvements
- Reload the sale page
- You should see header + skeleton immediately, then products load in
- Check Sentry → Web Vitals for
/sale:- FCP improves from > 3s to < 1.8s ✅
- LCP also improves with faster perceived loading
Verify Your Improvements
Section titled “Verify Your Improvements”Let’s verify that our fixes improved the Web Vitals scores across both pages.
-
Check the Web Vitals dashboard
- Navigate to Performance → Web Vitals
- View improvements for both pages:
Homepage (
/):- LCP: Should improve from > 4s to < 2.5s ✅
- CLS: Should improve from > 0.25 to < 0.1 ✅
Sale Page (
/sale):- FCP: Should improve from > 3s to < 1.8s ✅
-
Compare before and after
Use Sentry’s comparison view to see the impact:
- Filter by time range: Before your changes vs. After
- Look at the Performance Score improvement
- Check that all affected pages show better scores
-
Test on real devices
- Test on slow network (throttle to 3G in DevTools)
- Test on real mobile devices if available
- Verify skeleton screens appear immediately
- Confirm no layout shifts occur
Key Takeaways
Section titled “Key Takeaways”- All 5 Web Vitals tracked: LCP, CLS, FCP, TTFB, and INP combine into a Performance Score (0-100)
- LCP (30% weight): Optimize images with proper sizing (
width/heightattributes), aspect ratios, lazy loading, and CDN optimization - CLS (15% weight): Reserve space for dynamic content (banners, ads) and images to prevent layout shifts
- FCP (15% weight): Never block initial paint - always render page structure immediately with skeleton screens
- INP (30% weight): Ensure responsive interactions and avoid blocking the main thread
- Combined issues: Homepage performance issues (slow images + late-appearing banner) compound each other
- Frontend fixes:
- Add
aspect-ratiooraspect-squareto image containers - Reserve space for conditional UI elements with fixed-height containers
- Replace render-blocking patterns with skeleton screens
- Use
loading="eager"for above-the-fold images,loading="lazy"for below-the-fold
- Add
Production Checklist
Section titled “Production Checklist”Before deploying these frontend optimizations:
- Images have explicit
widthandheightattributes - Image containers use
aspect-ratioor Tailwindaspect-squareclasses - Critical above-the-fold images use
loading="eager" - Below-the-fold images use
loading="lazy" - Conditional UI elements (banners) reserve space with fixed-height containers
- Page structure (header, navigation) renders immediately - no full-page blocking states
- Skeleton screens implemented for data-dependent content
- Skeleton layout matches actual content grid/structure
- Tested on real devices and slow network connections (throttled to 3G)
- Performance Score improved in Sentry dashboard
Next up: We’ll trace frontend slowness to backend bottlenecks and fix N+1 queries using distributed tracing.