CDN Optimization for Faster LCP: A Complete Guide
Largest Contentful Paint is directly dependent on how fast your CDN can deliver the bytes that make up the LCP element -- usually a hero image, above-the-fold heading, or video poster frame. A well-configured CDN eliminates the two biggest contributors to slow LCP: geographic distance from the origin server, and unnecessarily large image payloads. Yet most sites leave significant performance gains on the table through poor cache key design, missing image CDN configuration, and absent resource hints.
CDN optimization for LCP involves more than pointing your domain at a CDN provider. The difference between a 3.8s LCP and a 1.4s LCP often comes down to whether your cache hit ratio is 40% or 90%, whether your LCP image is served as a 400 KB JPEG or an 85 KB AVIF, and whether the browser discovers and preconnects to your CDN origin before it parses your HTML. Each of these factors can independently account for hundreds of milliseconds.
This guide covers five CDN-specific optimizations: choosing the right tier and configuration, cache key design and TTLs, image CDN with format negotiation, resource hints for CDN assets, and monitoring cache hit rates. For platform-specific CDN setups, also see the guides on fixing TTFB on Vercel and fixing TTFB on Netlify, which cover the CDN behaviors specific to those hosting platforms. The complete LCP guide covers all LCP factors beyond CDN.
Expected results
Following all steps in this guide typically produces these improvements:
Before
3.8s
LCP (Poor) -- Large unoptimized images, low cache hit ratio, no resource hints, origin serving all requests
After
1.4s
LCP (Good) -- AVIF images from edge, 92% cache hit ratio, preconnect hints, sub-20ms edge TTFB
Step-by-step fix
Choose the right CDN tier and configuration
The foundation of CDN optimization is selecting the right provider and tier for your use case, then configuring the baseline settings correctly. Free CDN tiers from Cloudflare, Fastly, and Bunny.net are often sufficient for most sites. Premium tiers add SLA guarantees, advanced image processing, and programmatic cache purging APIs that matter at scale. For LCP, the most important CDN features are global edge coverage close to your users, HTTP/3 support, Brotli compression, and image transformation capabilities.
For sites with a concentrated audience (e.g., primarily US-based users), a regional CDN with a small number of well-placed PoPs may outperform a global CDN with hundreds of PoPs but higher per-edge overhead. For global audiences, prioritize CDNs with PoPs in Latin America, Southeast Asia, and Africa where alternative providers have sparse coverage.
# nginx.conf -- origin configuration for CDN-backed deployment
# Enable Brotli compression (CDN caches the compressed version)
brotli on;
brotli_comp_level 6;
brotli_types text/html text/css application/javascript
application/json image/svg+xml font/woff2;
# Tell CDN what can be cached and for how long
location ~* \.(css|js|woff2|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
}
location ~* \.(jpg|jpeg|png|webp|avif|gif)$ {
# Image CDN handles transformation; origin sets long TTL
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800";
}
location / {
# HTML: cache at CDN for 5 min, serve stale for 1 day
add_header Cache-Control "public, s-maxage=300, stale-while-revalidate=86400";
# Vary only on Accept-Encoding (not User-Agent -- that fragments cache)
add_header Vary "Accept-Encoding";
}
# wrangler.toml -- Cloudflare CDN configuration
name = "my-site"
compatibility_date = "2026-01-01"
# Route all traffic through Cloudflare CDN
routes = [
{ pattern = "example.com/*", zone_name = "example.com" }
]
# Cache rules via Cloudflare Cache Rules (set in dashboard or via API)
# Rule 1: Static assets -- cache everything, 1 year TTL
# Pattern: (http.request.uri.path matches "\.(css|js|woff2|png|jpg|avif)$")
# Action: Cache, Edge TTL = 31536000s, Browser TTL = 86400s
# Rule 2: HTML pages -- cache for 5 min
# Pattern: (http.request.uri.path matches "/$|\.html$")
# Action: Cache, Edge TTL = 300s, Respect Origin TTL = false
# Enable HTTP/3 (QUIC) -- dramatically reduces LCP on high-latency connections
# Dashboard: Speed > Optimization > Protocol Optimization > HTTP/3: ON
# Enable Brotli compression
# Dashboard: Speed > Optimization > Content Optimization > Brotli: ON
# Enable Early Hints (103 responses) -- browser preloads LCP image
# while CDN fetches the full HTML response from origin
# Dashboard: Speed > Optimization > Protocol Optimization > Early Hints: ON
// Multi-CDN health check script (runs server-side, e.g. as a cron job)
// Switches DNS to secondary CDN if primary latency exceeds threshold
const CDN_ENDPOINTS = {
primary: 'https://cdn1.example.com/health',
secondary: 'https://cdn2.example.com/health',
};
async function checkCDNHealth() {
const results = await Promise.allSettled(
Object.entries(CDN_ENDPOINTS).map(async ([name, url]) => {
const start = performance.now();
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
const latency = performance.now() - start;
return { name, latency, ok: res.ok };
})
);
for (const result of results) {
if (result.status === 'fulfilled') {
const { name, latency, ok } = result.value;
console.log(`CDN ${name}: ${ok ? 'healthy' : 'unhealthy'}, ${latency.toFixed(0)}ms`);
// Alert if primary CDN p50 latency exceeds 50ms
if (name === 'primary' && latency > 50) {
await sendAlert(`Primary CDN latency degraded: ${latency.toFixed(0)}ms`);
}
}
}
}
// Run every 60 seconds
setInterval(checkCDNHealth, 60_000);
Optimize cache key design and TTLs
Cache key design is the most impactful configuration change most sites can make. By default, many CDNs include the full query string in the cache key, meaning /page?utm_source=google and /page?utm_source=twitter are cached separately despite returning identical HTML. This fragments your cache and reduces your cache hit ratio (CHR) from a potential 90%+ to 30-40%.
The fix has two parts: strip tracking and irrelevant query parameters from the cache key, and normalize URL variations (trailing slashes, case) before the request reaches the cache lookup. This alone can double your CHR. Combined with appropriate TTLs -- longer for stable content, shorter for frequently changing pages -- you eliminate the majority of origin requests that contribute to high LCP.
// Cloudflare Worker: cache key normalization
// Deploy via wrangler or Cloudflare dashboard
const TRACKING_PARAMS = new Set([
'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
'fbclid', 'gclid', 'gclsrc', 'dclid', 'msclkid', '_ga', 'ref',
]);
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Normalize trailing slash (/ and no slash = same content)
if (url.pathname !== '/' && url.pathname.endsWith('/')) {
url.pathname = url.pathname.slice(0, -1);
}
// Lowercase the pathname (prevent /Blog and /blog cache split)
url.pathname = url.pathname.toLowerCase();
// Strip tracking params from cache key
for (const param of TRACKING_PARAMS) {
url.searchParams.delete(param);
}
// Sort remaining params for consistent key ordering
url.searchParams.sort();
// Build normalized cache request
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
// Cache miss: fetch from origin with original URL (tracking params preserved for analytics)
response = await fetch(request);
if (response.ok) {
// Clone and store in cache with normalized key
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
}
return response;
},
};
// next.config.js -- Cache-Control strategy by route category
/** @type {import('next').NextConfig} */
module.exports = {
async headers() {
return [
{
// Marketing pages: change infrequently -- 1 hour CDN cache
source: '/(about|pricing|features|contact)',
headers: [{
key: 'Cache-Control',
value: 'public, s-maxage=3600, stale-while-revalidate=86400',
}],
},
{
// Blog posts: stable after publish -- 24 hour CDN cache
source: '/blog/:slug',
headers: [{
key: 'Cache-Control',
value: 'public, s-maxage=86400, stale-while-revalidate=604800',
}],
},
{
// Homepage: changes frequently -- 1 min CDN cache
source: '/',
headers: [{
key: 'Cache-Control',
value: 'public, s-maxage=60, stale-while-revalidate=3600',
}],
},
{
// Immutable static assets (hashed filenames)
source: '/_next/static/:path*',
headers: [{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
}],
},
];
},
};
// TTL guidelines:
// Static assets (hashed): 1 year (immutable)
// Blog/docs content: 24h s-maxage + 7d stale-while-revalidate
// Product pages: 1h s-maxage + 24h stale-while-revalidate
// Homepage: 1-5min s-maxage + 1h stale-while-revalidate
// User-specific pages: Cache-Control: private (never CDN-cached)
Implement image CDN with automatic format negotiation
Images are the LCP element on the majority of web pages, and they are also the largest optimization opportunity. A hero image served as a 600 KB JPEG can become a 90 KB AVIF -- a 6x size reduction with no perceptible quality loss. An image CDN automates this by reading the browser's Accept header and serving AVIF to browsers that support it, WebP to others, and the original format as a fallback.
Beyond format conversion, image CDNs resize images to the exact dimensions requested, eliminating the common problem of serving a 2400×1600px image to a device that renders it at 400×267px. Cloudflare Image Resizing, Vercel Image Optimization, Imgix, Cloudinary, and Bunny Optimizer all provide this capability. The transformed images are cached at the edge, so the first request pays the transformation cost and all subsequent requests are served from cache. For more about image optimization strategies, see the image optimization for LCP guide.
// Cloudflare Image Resizing via Worker
// Route: /img/* -> this worker
// Requires Cloudflare Pro plan with Image Resizing enabled
export default {
async fetch(request) {
const url = new URL(request.url);
// Parse transform params from URL query string
// e.g. /img/hero.jpg?w=800&h=450&q=80
const width = parseInt(url.searchParams.get('w') || '0') || undefined;
const height = parseInt(url.searchParams.get('h') || '0') || undefined;
const quality = parseInt(url.searchParams.get('q') || '80');
// Detect best supported format from Accept header
const accept = request.headers.get('Accept') || '';
const format = accept.includes('image/avif') ? 'avif'
: accept.includes('image/webp') ? 'webp'
: 'jpeg';
// Build origin image URL (strip transform params)
const originUrl = `https://origin.example.com${url.pathname}`;
// Use Cloudflare's cf.image transform (edge-side, no origin round trip after cache)
return fetch(originUrl, {
cf: {
image: {
width,
height,
quality,
format,
fit: 'cover',
metadata: 'none', // Strip EXIF -- reduces file size
},
},
});
},
};
// Performance impact:
// JPEG 2400x1600 original: ~620 KB
// WebP 800x533 q=80: ~58 KB (91% smaller)
// AVIF 800x533 q=80: ~38 KB (94% smaller)
// LCP improvement: ~1.2s faster on 4G connections
<!-- Optimal LCP image markup with CDN-based image optimization -->
<!-- Use <picture> with type hints so browser skips formats it can't decode -->
<picture>
<source
type="image/avif"
srcset="
https://img.example.com/hero.jpg?w=400&f=avif 400w,
https://img.example.com/hero.jpg?w=800&f=avif 800w,
https://img.example.com/hero.jpg?w=1200&f=avif 1200w
"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
/>
<source
type="image/webp"
srcset="
https://img.example.com/hero.jpg?w=400&f=webp 400w,
https://img.example.com/hero.jpg?w=800&f=webp 800w,
https://img.example.com/hero.jpg?w=1200&f=webp 1200w
"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
/>
<img
src="https://img.example.com/hero.jpg?w=800&f=jpeg"
alt="Hero image description"
width="800"
height="450"
fetchpriority="high"
decoding="sync"
loading="eager"
/>
</picture>
<!-- Key attributes for LCP images:
fetchpriority="high" -- promotes to high-priority network request
loading="eager" -- never lazy-load the LCP image
decoding="sync" -- don't defer decode (prevents layout delay)
width + height -- prevents layout shift (CLS) as image loads
-->
// next.config.js -- configure custom image CDN loader
/** @type {import('next').NextConfig} */
module.exports = {
images: {
loader: 'custom',
loaderFile: './lib/image-loader.ts',
// Specify which external image origins are allowed
remotePatterns: [
{ protocol: 'https', hostname: 'img.example.com' },
{ protocol: 'https', hostname: 'assets.example.com' },
],
// Supported output sizes (CDN will cache each size separately)
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 86400, // 24h minimum edge cache for transformed images
},
};
// lib/image-loader.ts
export default function imageLoader({ src, width, quality }) {
const params = new URLSearchParams({
url: src,
w: width.toString(),
q: (quality || 80).toString(),
});
return `https://img.example.com/resize?${params}`;
}
Configure resource hints for CDN-delivered assets
Resource hints are HTML link elements that tell the browser to do preparatory work -- DNS lookups, TCP connections, TLS handshakes, and asset fetches -- before it actually needs those resources. For CDN-served content, the most valuable hints are preconnect to your CDN image origin and preload for the LCP image itself.
Without a preconnect hint, when the browser encounters your LCP image in the HTML, it must first resolve the CDN hostname (20-120ms), open a TCP connection (1 round trip), and complete TLS negotiation (1-2 round trips) before the first byte of image data can arrive. On a 4G connection with 80ms RTT, this connection setup alone adds 240-400ms to LCP. A preconnect hint initiates all three steps as soon as the browser parses the <head>, eliminating that latency entirely.
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CRITICAL: preconnect to CDN origins before any other resources -->
<!-- This eliminates DNS + TCP + TLS latency for the first CDN request -->
<link rel="preconnect" href="https://img.example.com" crossorigin>
<link rel="preconnect" href="https://cdn.example.com">
<!-- dns-prefetch for lower-priority third-party origins -->
<link rel="dns-prefetch" href="https://analytics.example.com">
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
<!-- CRITICAL: preload the LCP image -->
<!-- imagesrcset tells browser which src to preload based on viewport -->
<link
rel="preload"
as="image"
href="https://img.example.com/hero.jpg?w=1200&f=avif"
imagesrcset="
https://img.example.com/hero.jpg?w=400&f=avif 400w,
https://img.example.com/hero.jpg?w=800&f=avif 800w,
https://img.example.com/hero.jpg?w=1200&f=avif 1200w
"
imagesizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
fetchpriority="high"
crossorigin
/>
<!-- Preload critical web fonts from CDN -->
<link
rel="preload"
href="https://cdn.example.com/fonts/brand-regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<title>Example Page</title>
</head>
<!-- Resource hint performance impact (4G, 80ms RTT):
Without preconnect: +320ms for CDN connection setup
With preconnect: +0ms (connection already established)
Without preload LCP: LCP image discovered after HTML parse (~300ms)
With preload LCP: LCP image fetch starts immediately (<50ms)
Combined savings: 400-600ms improvement to LCP
-->
// app/layout.tsx -- Root layout with CDN resource hints
import type { Metadata } from 'next';
export const metadata: Metadata = {
// Next.js App Router injects these as <link> tags in <head>
other: {
// Preconnect hints (Next.js doesn't have a built-in API for these,
// so we render them manually in the layout component)
},
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
{/* CDN preconnect -- must appear before any resource from these origins */}
<link rel="preconnect" href="https://img.example.com" crossOrigin="anonymous" />
<link rel="preconnect" href="https://cdn.example.com" />
{/* Preload LCP image -- only on pages where this is the hero */}
{/* For dynamic LCP images, move this to the individual page component */}
</head>
<body>{children}</body>
</html>
);
}
// app/page.tsx -- Preload page-specific LCP image
export default function HomePage() {
const lcpImageUrl = 'https://img.example.com/homepage-hero.jpg';
return (
<>
{/* Inject preload hint for this page's LCP image */}
<link
rel="preload"
as="image"
href={`${lcpImageUrl}?w=1200&f=avif`}
// @ts-ignore -- imagesrcset is a valid attribute
imagesrcset={`${lcpImageUrl}?w=400&f=avif 400w, ${lcpImageUrl}?w=800&f=avif 800w, ${lcpImageUrl}?w=1200&f=avif 1200w`}
imagesizes="(max-width: 600px) 100vw, 1200px"
fetchPriority="high"
/>
<main>{/* page content */}</main>
</>
);
}
Monitor CDN performance and cache hit rates
CDN performance requires ongoing monitoring because cache hit rates degrade silently -- a new marketing campaign adds UTM parameters that fragment the cache, a developer sets a short TTL during testing and forgets to revert it, or a new A/B testing tool adds per-user cookies that prevent CDN caching entirely. Without visibility into your CHR and origin latency, these regressions go undetected until users complain.
The most reliable monitoring strategy combines three signals: Server-Timing headers that expose cache status to real user monitoring (RUM) tools, CDN analytics APIs that provide CHR and request volume, and synthetic testing that measures LCP from multiple geographic locations. Together, these give you both the high-level CDN efficiency metric and the granular per-URL data to diagnose issues. Set up alerts for CHR drops below your baseline -- a 10-point drop in CHR typically increases your origin load 2-3x and adds 200-800ms to median LCP.
// Cloudflare Worker: add Server-Timing headers for RUM observability
export default {
async fetch(request, env, ctx) {
const startTime = Date.now();
const response = await fetch(request);
const duration = Date.now() - startTime;
// Cloudflare exposes cache status via CF-Cache-Status header
const cacheStatus = response.headers.get('CF-Cache-Status') || 'UNKNOWN';
// Map Cloudflare cache statuses to timing metrics
// HIT = served from edge cache (fast)
// MISS = fetched from origin (slow)
// STALE = served stale content while revalidating
// BYPASS = cache bypassed (e.g., cookies present)
// EXPIRED = entry expired, fetching fresh from origin
const newHeaders = new Headers(response.headers);
newHeaders.set(
'Server-Timing',
[
`cdn;desc="${cacheStatus}";dur=${cacheStatus === 'HIT' ? 1 : duration}`,
`origin;dur=${cacheStatus !== 'HIT' ? duration : 0}`,
`total;dur=${duration}`,
].join(', ')
);
// Read this in RUM with PerformanceServerTiming API:
// const entries = performance.getEntriesByType('navigation');
// entries[0].serverTiming.forEach(t => console.log(t.name, t.duration));
return new Response(response.body, {
status: response.status,
headers: newHeaders,
});
},
};
// rum.js -- Track CDN cache status alongside LCP in real user monitoring
// Read Server-Timing entries from the navigation request
function getCDNTiming() {
const navEntry = performance.getEntriesByType('navigation')[0];
if (!navEntry?.serverTiming) return null;
const timings = {};
for (const entry of navEntry.serverTiming) {
timings[entry.name] = { duration: entry.duration, description: entry.description };
}
return timings;
}
// Read LCP from PerformanceObserver
const lcpData = { value: null };
new PerformanceObserver((list) => {
const entries = list.getEntries();
lcpData.value = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
// Send combined LCP + CDN cache status to your analytics endpoint
window.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
const cdn = getCDNTiming();
const payload = {
lcp: lcpData.value,
cdn_status: cdn?.cdn?.description, // 'HIT', 'MISS', 'STALE', etc.
origin_duration: cdn?.origin?.duration,
url: location.href,
timestamp: Date.now(),
};
// Use sendBeacon for non-blocking delivery
navigator.sendBeacon('/analytics/rum', JSON.stringify(payload));
});
// Correlate LCP with cache status to find pages where
// cache misses (MISS/EXPIRED) are causing poor LCP scores
Quick checklist
- CDN provider selected with HTTP/3, Brotli, and image optimization support
- Tracking query parameters (UTM, fbclid, gclid) stripped from CDN cache keys
-
LCP images served as AVIF/WebP with responsive
srcsetvia image CDN -
preconnecthint added to CDN image origin in<head> -
LCP image preloaded with
rel="preload"andfetchpriority="high" -
Server-Timingheaders expose cache status (HIT/MISS) to RUM tools - Cache hit ratio monitored with alerts for drops below 80% on static assets
Frequently asked questions
Yes. LCP measures when the largest visible element -- usually a hero image or heading -- finishes rendering. A CDN reduces the network latency component of LCP by serving assets from an edge node close to the user instead of a distant origin server. For image LCP elements, a CDN with image optimization can also reduce download size by 50-80% through format conversion and responsive sizing, which is often a larger win than latency alone. The combination of reduced transfer size and lower network latency typically improves LCP by 1-2 seconds on mid-tier mobile connections.
Aim for 85-95% CHR on static assets (images, JS, CSS) and 60-80% on HTML pages. A CHR below 60% on static assets indicates an inefficient TTL or cache key configuration. Common causes: query strings included in cache keys, overly short TTLs, Vary: User-Agent headers that fragment the cache, or URL inconsistencies like mixed trailing slashes. Track CHR per content type -- a low HTML CHR may be intentional (frequently updated content) while a low image CHR is almost always a misconfiguration.
Cloudflare's free plan delivers excellent LCP performance for most sites and includes unlimited bandwidth, HTTP/3, and Brotli compression. Upgrade to a paid tier when you need: SLA guarantees for business-critical applications, advanced image optimization (Cloudflare Polish/Mirage), cache rules beyond the free tier limits, load balancing across origins, or programmatic cache purge APIs. For LCP specifically, Cloudflare's free image resizing capabilities are often sufficient, though paid plans offer Cloudflare Images with automatic AVIF conversion and more granular quality controls.
Set Cache-Control: private or no-store on responses with user-specific content. For pages with a mix of shared and personalized sections, cache the shared page shell at the CDN and load personalized data via a separate authenticated API call from JavaScript. Alternatively, use edge-side includes (ESI) to compose a cached shell with a non-cached personalized fragment at the edge. Never rely on cookies alone to prevent caching -- explicitly set Cache-Control: private on any response that varies per user.
dns-prefetch only resolves the DNS hostname, saving roughly 20-120ms. preconnect performs DNS resolution plus TCP handshake plus TLS negotiation, saving 100-500ms total, but consumes more resources. Use preconnect for your primary CDN origin -- especially the one serving the LCP image. Use dns-prefetch for lower-priority third-party origins. Limit preconnect hints to 2-4 domains: too many compete for TCP connection bandwidth and can actually increase LCP by delaying other critical resource fetches.
Related resources
Complete LCP Guide
Deep dive into Largest Contentful Paint -- thresholds, measurement, and all optimization strategies.
FixFix TTFB on Vercel
Vercel-specific CDN and Edge Runtime optimizations for fast TTFB and LCP.
FixFix TTFB on Netlify
Netlify CDN configuration and Edge Functions for faster response times.
FixImage Optimization for LCP
Format conversion, responsive images, and lazy loading strategies to minimize LCP image payload.