Articles

SEO 17 min read

Web Performance Optimization: A Technical Guide to LCP and CLS

Advanced techniques to improve Core Web Vitals in ecommerce: Brotli compression, HTTP/3, SSR, placeholders and critical image optimization.

Jonathan Valenzuela
Jonathan Valenzuela SEO and digital marketing consultant
In this article 15 sections
  1. Why web performance is SEO
  2. Compression: from Gzip to Brotli
  3. HTTP/3: cutting connection latency
  4. CLS: the problem with JavaScript-loaded content
  5. LCP: don’t lazy-load critical images
  6. Preconnect: critical origins only
  7. font-display: avoid invisible text
  8. The <title> must be in the initial HTML
  9. Diagnostic tools
  10. Cache keys: don’t penalize campaign traffic
  11. Cache-Control: control how the HTML gets cached
  12. Resource order in <head>: third parties and LCP
  13. Don’t block rendering on user permissions
  14. Remove unnecessary redirects
  15. Prioritizing improvements

Why web performance is SEO

Google confirmed that Core Web Vitals are a ranking factor back in 2021. But beyond SEO, speed directly affects conversion: Amazon calculated that every additional 100ms of latency cost them 1% in sales.

Google’s current thresholds are clear:

MetricGoodNeeds improvementPoor
LCP≤2.5s2.5s - 4s>4s
INP≤200ms200ms - 500ms>500ms
CLS≤0.10.1 - 0.25>0.25

In ecommerce, LCP and CLS cause the most trouble. INP is usually under control if you don’t overdo the JavaScript.

Compression: from Gzip to Brotli

Most sites use Gzip to compress text. It works, but Brotli offers 15-20% better compression on text files (HTML, CSS, JS, JSON).

AlgorithmCompression ratioSpeedSupport
Gzip~70%FastUniversal
Brotli~80-85%Slower to compress97%+ of browsers

How to check which compression you use

Open DevTools → Network → select a text file → Headers → look for content-encoding:

  • gzip = Gzip
  • br = Brotli
  • Empty = no compression (a serious problem)

Implementation

In Nginx:

brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml;

On a CDN (Cloudflare, Akamai, Fastly): it’s usually a checkbox in the settings. Akamai requires you to enable it explicitly in the delivery configuration.

HTTP/3: cutting connection latency

With HTTP/2 and TLS 1.3, connection negotiation takes roughly 288ms before the first request can be made. HTTP/3 cuts that in half: ~144ms.

ProtocolHandshakeMultiplexingHead-of-line blocking
HTTP/1.1Separate TCP + TLSNoYes
HTTP/2TCP + TLSYesAt the TCP level
HTTP/3QUIC (built in)YesNo

HTTP/3 uses QUIC instead of TCP, which removes head-of-line blocking and reduces initial latency. On mobile connections or with packet loss, the difference is more noticeable.

How to check it

In DevTools → Network → the “Protocol” column. You should see h3 for HTTP/3.

Implementation

Most modern CDNs support HTTP/3:

  • Cloudflare: on by default
  • Akamai: a checkbox in the property configuration
  • Fastly: requires manual activation
  • AWS CloudFront: supported since 2022

If you serve from origin without a CDN, you need to configure your web server for QUIC, which is more complex.

CLS: the problem with JavaScript-loaded content

CLS measures how much content “jumps” while it loads. The worst case: infinite scroll or badly implemented lazy loading where content appears and pushes away what the user was reading.

Common causes of high CLS

CauseCLS impactSolution
Images without dimensionsHighAdd width and height
Dynamic adsVery highReserve fixed space
Web fonts (FOUT)Mediumfont-display: optional
Content injected by JSHighSSR or placeholders
Infinite scrollVery highPagination or SSR

Server-side rendering vs. client-side

If your main content is rendered with JavaScript on the client, you have two problems:

  1. LCP gets delayed because the browser has to download the JS, parse it, run it and only then render
  2. CLS blows up because the reserved space doesn’t match the final content

The ideal solution is server-side rendering (SSR): the HTML arrives complete from the server. Frameworks such as Next.js, Nuxt, Astro or SvelteKit support it natively.

If SSR isn’t an option, you need perfect placeholders.

Placeholders that work

An effective placeholder must:

  1. Take up exactly the same space as the final content
  2. Be responsive so it works on every viewport
  3. Use viewport units so it adapts without recalculating
/* Placeholder for a product card */
.product-card-placeholder {
  aspect-ratio: 4/3;
  width: 100%;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

The trick is aspect-ratio: it keeps the proportion regardless of width and causes no layout shift when the real content replaces the placeholder.

Don’t use loading="lazy" on images inserted by JS

If you already lazy-load images with JavaScript (as with infinite scroll), adding loading="lazy" is redundant and counterproductive: you’re applying lazy on top of lazy.

<!-- WRONG: double lazy loading -->
<img src="product.webp" loading="lazy">
<!-- JS already inserted the image when it was needed -->

<!-- RIGHT: no loading attributes on JS images -->
<img src="product.webp">
<!-- The browser loads it right away because it's in the DOM -->

For images inserted dynamically by JavaScript, my recommendation is to use neither loading nor fetchpriority. The browser already knows the image is needed (it’s in the DOM) and will do the right thing without intervention.

LCP: don’t lazy-load critical images

The LCP element is usually the main hero image or the largest product image. A common mistake is applying loading="lazy" to every image, critical ones included.

<!-- WRONG: lazy load on the LCP image -->
<img src="hero.webp" loading="lazy" alt="Hero">

<!-- RIGHT: no lazy load on the LCP image -->
<img src="hero.webp" alt="Hero" fetchpriority="high">

Finding out which image is the LCP

  1. DevTools → Performance → record a page load
  2. Look for the “LCP” marker in the timeline
  3. Click the marker to see which element it is

You can also use this temporary CSS to spot lazy-loaded images:

img[loading="lazy"] {
  border: 10px solid red !important;
}

Optimizing the LCP image

TechniqueImpact
Remove loading="lazy"High
Add fetchpriority="high"Medium
Preload with <link>High
WebP/AVIF formatMedium
Correct size (no scaling)Medium
Serve from a CDNHigh
Reduce file sizeHigh

Optimize image weight

It’s common to find 1MB thumbnails that should weigh 50KB. The usual causes:

ProblemSolution
Uncompressed original imageCompress at 80-85% quality
Wrong formatWebP or AVIF instead of PNG/JPEG
Excessive resolutionServe the real size, don’t scale in the browser
No optimizing CDNUse a CDN that optimizes on the fly

Compression tools:

  • Squoosh (web): visual comparison of formats and quality
  • ImageOptim (Mac): local batch processing
  • Sharp (Node): automation at build time
<!-- Serve the image at the size it's displayed -->
<img src="product-400w.webp"
     srcset="product-400w.webp 400w,
             product-800w.webp 800w"
     sizes="(max-width: 600px) 400px, 800px"
     alt="Product">

A 400x300px thumbnail shouldn’t weigh more than 50-80KB in WebP at 80% quality.

<!-- Preload the LCP image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">

Preconnect: critical origins only

preconnect tells the browser to start the connection (DNS + TCP + TLS) to a domain before it needs it. It saves the handshake time when the request is finally made.

But there’s a cost: every preconnect uses browser resources. If you preconnect to 10 domains but only 2 are critical, you’re wasting resources on connections that don’t matter.

What to preconnect

Only the origins that block rendering or affect LCP:

OriginPreconnect?Reason
CDN for critical imagesYesDirectly affects LCP
Cookie consent (Cookielaw, OneTrust)YesCan be the LCP if it shows a banner
Google FontsYesBlocks text rendering
AnalyticsNoNot critical for the user
Tracking pixelsNoDeferred loading is fine
Social embedsNoUsually below the fold

Correct implementation

<head>
  <!-- Preconnect only to critical origins -->
  <link rel="preconnect" href="https://cdn.yourdomain.com">
  <link rel="preconnect" href="https://cdn.yourdomain.com" crossorigin>
  <link rel="preconnect" href="https://cdn.cookielaw.org">
  <link rel="preconnect" href="https://cdn.cookielaw.org" crossorigin>

  <!-- DON'T do this: -->
  <!-- <link rel="dns-prefetch" href="..."> (preconnect already includes DNS) -->
  <!-- <link rel="preconnect" href="https://google-analytics.com"> (not critical) -->
</head>

Note: you need two lines per domain if you serve both CORS resources (fonts, fetch requests) and non-CORS resources (images): one with crossorigin and one without.

Remove any dns-prefetch if you already have a preconnect to the same domain. It’s redundant.

font-display: avoid invisible text

Web fonts can cause FOIT (Flash of Invisible Text): the browser waits for the font to load before showing the text. During that wait, the user sees… nothing.

The problem

Without font-display, the default behavior varies between browsers, but many wait up to 3 seconds before showing a fallback font. That’s unacceptable.

/* WRONG: no font-display */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  /* The browser decides what to do */
}

font-display options

ValueBehaviorBest for
swapShows the fallback, swaps when loadedText fonts
optionalShows the fallback, only swaps if it loads very fastMaximum performance
blockInvisible text for up to 3sNever (bad UX)
fallbackA compromise between swap and optionalSpecific cases

My recommendation:

  • font-display: swap for main text fonts
  • font-display: optional if performance is critical and you can live without showing the web font
/* RIGHT: explicit font-display */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap;
}

Auditing fonts without font-display

Search your CSS for every @font-face rule and check that it has font-display. If you use Google Fonts, add &display=swap to the URL:

<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">

The <title> must be in the initial HTML

If your <title> is injected with JavaScript, users see an empty tab or generic text until the JS runs. This affects perceived speed and can confuse users who have several tabs open.

<!-- WRONG: title injected by JS -->
<head>
  <script>document.title = 'My Page';</script>
</head>

<!-- RIGHT: title in the HTML -->
<head>
  <title>My Page | My Site</title>
</head>

In SPA frameworks, make sure SSR includes the correct <title> for every route. Next.js, Nuxt and similar frameworks handle it automatically if you use their metadata systems.

Diagnostic tools

ToolUseData type
PageSpeed InsightsQuick auditLab + Field
Chrome DevToolsDetailed debuggingLab
WebPageTestAdvanced tests, filmstripLab
Search Console (CWV)Real user dataField
CrUX DashboardMetric historyField

Field data (real users) is what Google uses for ranking. Lab data (tools) is useful for debugging but can differ from the real experience.

Cache keys: don’t penalize campaign traffic

A common CDN problem: every URL with different UTM parameters is treated as a separate cache key. This means the first user arriving from a marketing campaign gets a full cache miss and has to wait for the origin’s response.

# These URLs generate 4 different cache keys:
/product?utm_source=google
/product?utm_source=meta
/product?utm_medium=cpc
/product?utm_campaign=summer2026

If your origin takes 2+ seconds to respond, you’re giving the worst possible experience to new users coming from ads.

Solution: ignore tracking parameters in the cache key

Configure your CDN to exclude these parameters from the cache key:

ParameterUseInclude in the cache key?
utm_*Google AnalyticsNo
gclidGoogle AdsNo
fbclidFacebook AdsNo
msclkidMicrosoft AdsNo
page, sortPagination/filtersYes
variant, sizeProduct variantYes

In Akamai, you set this up in the cache key rules with a parameter allowlist. In Cloudflare, use Cache Rules to ignore specific query strings.

Checking it

# Should return HIT in both cases
curl -sI "https://yourdomain.com/product" | grep cache
curl -sI "https://yourdomain.com/product?utm_source=test" | grep cache

Cache-Control: control how the HTML gets cached

A common problem: HTML responses come without cache-control headers. Without explicit instructions, the browser applies its own heuristics, which leads to unpredictable behavior.

Cache-Control options for HTML

DirectiveBehaviorWhen to use it
no-storeNever cacheHighly dynamic content, sensitive data
no-cacheCache but always revalidateHTML that can change often
max-age=86400, must-revalidateCache for 24h, then revalidateSites with infrequent deploys

My recommendation for most static sites or sites with weekly deploys:

Cache-Control: max-age=86400, must-revalidate

This lets the browser serve from cache for 24 hours, reducing latency on repeat visits. must-revalidate ensures that once it expires, stale content isn’t used without checking with the server.

Checking it

In DevTools → Network → select the HTML document → Headers → look for cache-control. If it’s empty, you have a problem.

For more detail on cache directives, Harry Roberts’ Cache-Control for Civilians is the best reference.

Resource order in <head>: third parties and LCP

The order of resources in the <head> matters more than it seems. If you inject third parties with async snippets after your main render-blocking CSS and JS, those snippets don’t run until the main resources have finished loading.

The common problem

<head>
  <!-- Render-blocking JS and CSS first -->
  <link rel="stylesheet" href="/styles.css">
  <script src="/main.js"></script>

  <!-- Third parties afterwards: they wait for the above to finish -->
  <script async>
    // Cookie consent, analytics, etc.
  </script>
</head>

The async snippet doesn’t start running (and so doesn’t request the third-party script) until styles.css and main.js have finished.

The solution

Move inline third-party snippets before the render-blocking resources:

<head>
  <!-- Third parties first: they run earlier -->
  <script async>
    // Cookie consent (can be the LCP if it shows a banner)
  </script>

  <!-- Then the render-blocking JS and CSS -->
  <link rel="stylesheet" href="/styles.css">
  <script src="/main.js"></script>
</head>

This is especially important for cookie banners: if the banner is visible above the fold, it can become your LCP candidate. Requesting the banner script earlier means it gets painted earlier.

Don’t block rendering on user permissions

A serious mistake on pages that ask for geolocation (like ecommerce store locators): rendering nothing until the user accepts or rejects the permission.

The problem

If you make rendering depend on the user’s decision:

  1. The user lands on the page
  2. The “Allow location” dialog appears
  3. The user thinks about it, gets distracted, or simply ignores it
  4. The page stays blank
  5. LCP = the time until the user clicks

This produces potentially infinite LCP scores. If the user takes 30 seconds to decide, your LCP is 30 seconds.

The solution

Decouple rendering from the user’s decision:

// WRONG: block until there's permission
navigator.geolocation.getCurrentPosition(
  (pos) => renderPage(pos),  // Only renders if they accept
  () => renderPage(null)      // Only renders if they reject
);
// In the meantime: blank screen

// RIGHT: render first, personalize afterwards
renderPage();  // Renders immediately with a default state
navigator.geolocation.getCurrentPosition(
  (pos) => updateWithLocation(pos),  // Improves the experience if they accept
  () => showManualSearch()            // Offers an alternative if they reject
);

The main content should be visible beneath the permission dialog. The user can see the page while deciding.

General principle

Never make the first paint depend on a user action. Render first with a reasonable default state, and improve the experience afterwards if the user grants additional permissions.

Remove unnecessary redirects

Every redirect adds a full round trip before the browser can start downloading the real content. On slow or high-latency connections, this can add 200-500ms to LCP.

Common redirects you should remove

Type of redirectExampleSolution
www vs. non-wwwexample.com → www.example.comLink directly to the canonical version
HTTP → HTTPShttp:// → https://HSTS preload, always link with HTTPS
Trailing slash/page → /page/Consistent internal links
Country redirect/ → /es/Serve localized content without a redirect
Mobile redirectwww → m.Responsive design instead of a mobile site
Marketing redirects/campaign → /product?utm=...Link directly to the final URL

How to detect them

In DevTools → Network → filter for the HTML document → check the “Status” column. If you see 301 or 302, you have a redirect.

You can also use:

curl -sI "https://yourdomain.com" | grep -i location

If it returns a Location header, there’s a redirect.

Redirect chains

The worst case is a chain: http://example.com → https://example.com → https://www.example.com → https://www.example.com/es/. Every hop is an extra round trip.

Audit your internal links and make sure they point directly to the final URL, without going through intermediate redirects.

Prioritizing improvements

If you have to choose where to start:

  1. Optimized LCP image: biggest impact on LCP, easy to implement
  2. SSR or placeholders: fixes CLS at the root
  3. Brotli: improves every metric, a server setting
  4. HTTP/3: improves latency, only needs switching on in the CDN
  5. Cache-Control on HTML: improves repeat visits
  6. Third-party order: improves LCP when there are banners

Server optimizations (Brotli, HTTP/3, Cache-Control) are done once and benefit the whole site. Content optimizations (images, placeholders) require a page-by-page review.

Google documents every metric and technique on web.dev. It’s the most up-to-date reference.

Keep reading

See all articles