Why Your Web Vitals Are Suffering (And How to Fix Them)
I ran Lighthouse on a client's landing page last week. Performance score: 34. The page looked fine to me on my fiber connection, but the numbers told a different story. LCP was 8.2 seconds. Users on slower connections were having a miserable experience.
The culprit? A 3MB hero image served from the origin server, plus a dozen unoptimized product photos loading all at once. Classic asset delivery problems masquerading as "the site is just slow."
Let me show you how to diagnose and fix the most common Web Vitals issues related to asset delivery.
Understanding the Three Core Web Vitals
Before we fix anything, let's understand what we're measuring:
LCP (Largest Contentful Paint) measures when the main content becomes visible. For most pages, this is the hero image or a large text block. Target: under 2.5 seconds.
INP (Interaction to Next Paint) measures responsiveness when users click or tap. Large JavaScript bundles and render-blocking resources hurt this. Target: under 200ms.
CLS (Cumulative Layout Shift) measures visual stability—how much the page jumps around as it loads. Images without dimensions are a major cause. Target: under 0.1.
Diagnosing Your Issues
Start with PageSpeed Insights. It shows both lab data (simulated under controlled conditions) and field data (real-world user experiences). Pay attention to field data—that's what Google uses for ranking. Lab data is useful for debugging, but field data reflects what your actual users experience.
Look for these red flags:
Slow LCP: Check if your largest image is:
- Hosted on your origin server (not a CDN)
- Uncompressed or in an inefficient format (PNG instead of WebP)
- Too large for its display size
- Not preloaded
Poor INP: Look for:
- Large JavaScript bundles blocking the main thread
- Too many synchronous scripts
- Heavy third-party widgets
High CLS: Watch for:
- Images without width/height attributes
- Ads or embeds that load late
- Web fonts causing text reflow
Fix #1: Move Images to a CDN
This is the highest-impact fix for most sites. When images load from your origin server, users far from that server experience significant delays.
Here's a quick migration script. You'll need Node.js, sharp for image processing, and an easyCDN account with a secret key—install with npm install @easycdn/server sharp:
import { createClient } from '@easycdn/server'
import { readdir, readFile } from 'fs/promises'
import { join, extname } from 'path'
import sharp from 'sharp'
const client = createClient({
secretKey: process.env.EASYCDN_SECRET_KEY!,
})
async function migrateImages(sourceDir: string) {
const files = await readdir(sourceDir)
for (const file of files) {
const ext = extname(file).toLowerCase()
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) continue
const buffer = await readFile(join(sourceDir, file))
try {
// Convert to WebP for better compression
const optimized = await sharp(buffer)
.webp({ quality: 85 })
.toBuffer()
const result = await client.upload(optimized, {
fileName: file.replace(/\.[^.]+$/, '.webp'),
})
console.log(`${file} -> ${result.asset.url}`)
} catch (error) {
console.error(`Failed: ${file}`, error)
}
}
}
migrateImages('./public/images')Converting to WebP cuts file sizes by 25-35%.
Fix #2: Preload Your LCP Image
Once your hero image is on a CDN, tell the browser to fetch it early:
<head>
<link
rel="preload"
as="image"
href="https://cdn.easycdn.co/your-project/hero.webp"
fetchpriority="high"
/>
</head>This can shave hundreds of milliseconds off LCP by starting the download before the browser discovers the image in the HTML.
Fix #3: Add Dimensions to Every Image
Images without width and height cause layout shifts as they load. The browser doesn't know how much space to reserve, so content jumps when images finally render.
// Bad - causes CLS
<img src="product.webp" alt="Product" />
// Good - prevents CLS
<img
src="product.webp"
alt="Product"
width={800}
height={600}
/>If you don't know the dimensions, use aspect-ratio in CSS:
.product-image {
aspect-ratio: 4 / 3;
width: 100%;
height: auto;
}Fix #4: Lazy Load Below-the-Fold Images
Every image that loads competes for bandwidth with your LCP element. Defer images users won't see immediately:
// Above the fold - load immediately
<img
src="https://cdn.easycdn.co/hero.webp"
alt="Hero"
fetchPriority="high"
width={1200}
height={600}
/>
// Below the fold - lazy load
<img
src="https://cdn.easycdn.co/product.webp"
alt="Product"
loading="lazy"
width={400}
height={300}
/>Native lazy loading is supported in all modern browsers and requires zero JavaScript.
Fix #5: Optimize Image Sizes
Don't serve a 4000px image when users only see it at 800px. Create appropriately sized versions. If your CDN supports server-side transforms, you can generate multiple sizes during upload—with easyCDN, use the transform option:
const sizes = [400, 800, 1200]
const variants = await Promise.all(
sizes.map(async (width) => {
const result = await client.upload(imageBuffer, {
fileName: `hero-${width}.webp`,
transform: { image: { width, format: 'webp', quality: 85 } },
})
return { width, url: result.asset.url }
})
)Then use srcset so the browser picks the best size based on viewport and device pixel ratio:
<img
src={variants[1].url}
srcSet={variants.map(({ url, width }) => `${url} ${width}w`).join(', ')}
sizes="(max-width: 600px) 100vw, 800px"
alt="Hero image"
width={800}
height={400}
/>Fix #6: Defer Non-Critical JavaScript
INP suffers when large JavaScript bundles block the main thread. Defer scripts that aren't needed for initial render:
<!-- Critical scripts load normally -->
<script src="critical.js"></script>
<!-- Non-critical scripts are deferred -->
<script defer src="analytics.js"></script>
<script defer src="chat-widget.js"></script>The defer attribute tells the browser to download the script in parallel but wait to execute until after the HTML is parsed. This keeps the main thread free for user interactions.
Fix #7: Set Proper Cache Headers
Long cache times mean returning visitors load instantly. CDNs typically handle this automatically, but verify your headers in the Network tab:
Cache-Control: public, max-age=31536000, immutableFor assets with hashes in the filename (like app.a3f2b1.js), you can cache forever. The filename changes when content changes.
Measuring Your Progress
After each fix, run PageSpeed Insights again. You should see improvements in:
- LCP: CDN delivery + preloading
- CLS: Image dimensions + reserved space
- Total Blocking Time: Reduced with lazy loading
For ongoing monitoring, set up a performance budget in your CI:
# Using Lighthouse CI
lhci autorun --assert.preset=lighthouse:recommendedReal-World Impact
After implementing these fixes on the client site I mentioned:
| Metric | Before | After |
|---|---|---|
| LCP | 8.2s | 1.8s |
| CLS | 0.32 | 0.04 |
| Performance Score | 34 | 89 |
The biggest gains came from moving images to a CDN and adding proper dimensions. Total time invested: about 2 hours.
Quick Wins Checklist
Here's a prioritized list for immediate impact:
- Move your LCP image to a CDN
- Add
fetchpriority="high"to your hero image - Add width/height to all images
- Convert images to WebP format
- Add
loading="lazy"to below-fold images - Remove render-blocking resources from
<head> - Test with PageSpeed Insights
Wrapping Up
Most Web Vitals issues trace back to asset delivery. Large images, slow servers, missing dimensions—these are fixable problems that don't require rewriting your app.
The pattern is simple: serve optimized images from edge locations, tell the browser what to prioritize, and reserve space for content that loads asynchronously.
Your users (and Google) will notice the difference immediately.
If you're tired of wrestling with image optimization, easyCDN handles hosting and global delivery out of the box. Upload your images, swap out the URLs, and let the CDN do the heavy lifting. The free tier is enough to test the impact on your own site.
