In this article 15 sections
- Why web performance is SEO
- Compression: from Gzip to Brotli
- HTTP/3: cutting connection latency
- CLS: the problem with JavaScript-loaded content
- LCP: don’t lazy-load critical images
- Preconnect: critical origins only
- font-display: avoid invisible text
- The <title> must be in the initial HTML
- Diagnostic tools
- Cache keys: don’t penalize campaign traffic
- Cache-Control: control how the HTML gets cached
- Resource order in <head>: third parties and LCP
- Don’t block rendering on user permissions
- Remove unnecessary redirects
- 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:
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP | ≤2.5s | 2.5s - 4s | >4s |
| INP | ≤200ms | 200ms - 500ms | >500ms |
| CLS | ≤0.1 | 0.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).
| Algorithm | Compression ratio | Speed | Support |
|---|---|---|---|
| Gzip | ~70% | Fast | Universal |
| Brotli | ~80-85% | Slower to compress | 97%+ of browsers |
How to check which compression you use
Open DevTools → Network → select a text file → Headers → look for content-encoding:
gzip= Gzipbr= 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.
| Protocol | Handshake | Multiplexing | Head-of-line blocking |
|---|---|---|---|
| HTTP/1.1 | Separate TCP + TLS | No | Yes |
| HTTP/2 | TCP + TLS | Yes | At the TCP level |
| HTTP/3 | QUIC (built in) | Yes | No |
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
| Cause | CLS impact | Solution |
|---|---|---|
| Images without dimensions | High | Add width and height |
| Dynamic ads | Very high | Reserve fixed space |
| Web fonts (FOUT) | Medium | font-display: optional |
| Content injected by JS | High | SSR or placeholders |
| Infinite scroll | Very high | Pagination or SSR |
Server-side rendering vs. client-side
If your main content is rendered with JavaScript on the client, you have two problems:
- LCP gets delayed because the browser has to download the JS, parse it, run it and only then render
- 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:
- Take up exactly the same space as the final content
- Be responsive so it works on every viewport
- 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
- DevTools → Performance → record a page load
- Look for the “LCP” marker in the timeline
- 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
| Technique | Impact |
|---|---|
Remove loading="lazy" | High |
Add fetchpriority="high" | Medium |
Preload with <link> | High |
| WebP/AVIF format | Medium |
| Correct size (no scaling) | Medium |
| Serve from a CDN | High |
| Reduce file size | High |
Optimize image weight
It’s common to find 1MB thumbnails that should weigh 50KB. The usual causes:
| Problem | Solution |
|---|---|
| Uncompressed original image | Compress at 80-85% quality |
| Wrong format | WebP or AVIF instead of PNG/JPEG |
| Excessive resolution | Serve the real size, don’t scale in the browser |
| No optimizing CDN | Use 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:
| Origin | Preconnect? | Reason |
|---|---|---|
| CDN for critical images | Yes | Directly affects LCP |
| Cookie consent (Cookielaw, OneTrust) | Yes | Can be the LCP if it shows a banner |
| Google Fonts | Yes | Blocks text rendering |
| Analytics | No | Not critical for the user |
| Tracking pixels | No | Deferred loading is fine |
| Social embeds | No | Usually 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
| Value | Behavior | Best for |
|---|---|---|
swap | Shows the fallback, swaps when loaded | Text fonts |
optional | Shows the fallback, only swaps if it loads very fast | Maximum performance |
block | Invisible text for up to 3s | Never (bad UX) |
fallback | A compromise between swap and optional | Specific cases |
My recommendation:
font-display: swapfor main text fontsfont-display: optionalif 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
| Tool | Use | Data type |
|---|---|---|
| PageSpeed Insights | Quick audit | Lab + Field |
| Chrome DevTools | Detailed debugging | Lab |
| WebPageTest | Advanced tests, filmstrip | Lab |
| Search Console (CWV) | Real user data | Field |
| CrUX Dashboard | Metric history | Field |
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:
| Parameter | Use | Include in the cache key? |
|---|---|---|
utm_* | Google Analytics | No |
gclid | Google Ads | No |
fbclid | Facebook Ads | No |
msclkid | Microsoft Ads | No |
page, sort | Pagination/filters | Yes |
variant, size | Product variant | Yes |
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
| Directive | Behavior | When to use it |
|---|---|---|
no-store | Never cache | Highly dynamic content, sensitive data |
no-cache | Cache but always revalidate | HTML that can change often |
max-age=86400, must-revalidate | Cache for 24h, then revalidate | Sites 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:
- The user lands on the page
- The “Allow location” dialog appears
- The user thinks about it, gets distracted, or simply ignores it
- The page stays blank
- 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 redirect | Example | Solution |
|---|---|---|
| www vs. non-www | example.com → www.example.com | Link directly to the canonical version |
| HTTP → HTTPS | http:// → https:// | HSTS preload, always link with HTTPS |
| Trailing slash | /page → /page/ | Consistent internal links |
| Country redirect | / → /es/ | Serve localized content without a redirect |
| Mobile redirect | www → 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:
- Optimized LCP image: biggest impact on LCP, easy to implement
- SSR or placeholders: fixes CLS at the root
- Brotli: improves every metric, a server setting
- HTTP/3: improves latency, only needs switching on in the CDN
- Cache-Control on HTML: improves repeat visits
- 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.