Edge Delivery Services · 19 of 29
Measuring and validating EDS performance. Covers Core Web Vitals, Lighthouse, PageSpeed Insights, LCP, CLS, INP, common regressions, and Lighthouse CI.
This chapter covers:
EDS was designed from the ground up for performance. The architecture eliminates server-side rendering latency, loads the absolute minimum CSS and JS eagerly, and uses edge CDN delivery for sub-millisecond response times. Adobe claims EDS pages can achieve a 100/100 Lighthouse score out of the box.
But "out of the box" means "before you add blocks." Every block you write is a potential performance regression. Every image without dimensions causes layout shift. Every third-party script added to head.html blocks rendering.
Performance in EDS is structural, but it is not automatic once you start developing. You can break it. You need to measure to know you haven't.
Google's Core Web Vitals (CWV) are the metrics that determine whether a page is "good" for users. They affect Google Search ranking. They are the metrics EDS optimizes for.
What it measures: How long it takes for the largest visible content element (usually a hero image or heading) to fully render on screen.
Target:
| Score | LCP |
|---|---|
| Good ✅ | < 2.5 seconds |
| Needs improvement ⚠️ | 2.5s – 4.0s |
| Poor ❌ | > 4.0 seconds |
What the LCP element typically is in EDS:
<img> or <picture> element<h1> heading if no large image is above the foldWhy EDS optimizes LCP well:
srcset) reduce file size for each viewportWhat it measures: The total amount of unexpected layout shift that occurs on the page during load. If an image loads and pushes content down, that is a shift. If a font loads and changes line lengths, that can cause shift.
Target:
| Score | CLS |
|---|---|
| Good ✅ | < 0.1 |
| Needs improvement ⚠️ | 0.1 – 0.25 |
| Poor ❌ | > 0.25 |
CLS is measured as a cumulative score. Every unexpected shift contributes. A score of 0.1 means a very small or well-controlled amount of layout shift.
EDS-specific CLS causes:
width and height attributes (browser can't reserve space)What it measures: How responsive the page is to user interactions (clicks, taps, keyboard input). Replaced FID (First Input Delay) as the third CWV metric in March 2024.
Target:
| Score | INP |
|---|---|
| Good ✅ | < 200 milliseconds |
| Needs improvement ⚠️ | 200ms – 500ms |
| Poor ❌ | > 500 milliseconds |
EDS-specific INP causes:
decorate() blocking the main threadEDS guarantees that the boilerplate, correctly used, will achieve a 100/100 Lighthouse score. This means:
<head> (EDS loads all JS as modules with type="module" which is deferred by default)styles.css is minimal (< 14KB is a practical target)font-display: swapdelayed.js phase (1.3s+ after page load)The guarantee breaks when you:
<script> tags to head.htmlscripts.js before loadEager completes)width and height attributesdelayed.js phaseURL: https://pagespeed.web.dev
What it gives you:
How to use it with EDS:
Test against your preview URL (.aem.page) not localhost, because:
https://pagespeed.web.dev/report?url=https://main--eds-poc--codewithnaveenrapelli.aem.page/
Run it on:
How to open: DevTools → "Lighthouse" tab
Advantages over PageSpeed Insights:
Settings for EDS testing:
How to test against preview:
.aem.page URL in ChromeFor deep diagnosis of what is causing slow LCP or INP.
Finding the LCP element: In the Performance recording, look for the "LCP" marker in the Timings row. Click it. The tooltip shows:
If LCP is <img> in your hero block, that is expected. If it is a small inline image or text element, you have a problem — the real hero image is loading too late.
Install from: Chrome Web Store → "Web Vitals"
Shows a live CWV badge for any page you visit. While the page loads, the extension displays:
Green badge = Good. Yellow = Needs improvement. Red = Poor.
How to use during EDS development:
.aem.page URLThis is the fastest feedback loop for performance during active development.
URL: https://lookerstudio.google.com/c/reporting/55bc8fad-44c2-4280-aa0b-5f3f0cd3d2b8
CrUX (Chrome User Experience Report) is field data — real measurements from real users on real devices. Available after:
Field data is more important than lab data for SEO ranking purposes. Lab data (Lighthouse) is a simulation. CrUX is what Google actually sees.
For a new EDS site, you won't have CrUX data yet. Use Lighthouse and PageSpeed Insights lab data for development validation.
<!-- head.html — DESTROYS performance -->
<script src="https://cdn.example.com/tracking.js"></script>
<script src="/scripts/legacy.js"></script>
Effect: Any <script> in <head> that is not type="module" is render-blocking. Lighthouse penalizes immediately. LCP can jump from 1.2s to 4s+ for a single tracking pixel.
Fix: All third-party scripts belong in delayed.js. Anything in head.html must be <meta> or <link rel="preconnect"> only.
// scripts/delayed.js — the ONLY place for third-party scripts
import { loadScript } from './aem.js';
// Runs 1.3 seconds after page load
await loadScript('https://cdn.example.com/tracking.js');
/* styles/styles.css — BAD: 200KB of global CSS */
@import url('components/everything.css');
@import url('vendor/bootstrap.min.css');
Effect: styles.css is loaded in the eager phase. Every byte in this file delays First Contentful Paint.
Practical target: Keep styles.css under 14KB uncompressed (after HTTP compression, this is ~ 4KB).
Fix: Move non-critical CSS to lazy-styles.css or to individual block CSS files.
/* styles/styles.css — ONLY critical above-the-fold styles */
/* This file must be < 14KB */
:root { /* tokens */ }
body { /* base layout */ }
header, main, footer { /* structural layout */ }
h1, h2, h3, h4 { /* heading defaults */ }
/* Block-specific CSS → loaded only when block is on page → lazy phase */
/* vendor CSS → lazy-styles.css or delayed.js */
<!-- BAD — browser doesn't know height until image loads, causes layout shift -->
<img src="hero.webp" alt="Hero image">
<!-- GOOD — browser reserves exact space before image loads, no shift -->
<img src="hero.webp" alt="Hero image" width="1200" height="640">
Effect on CLS: An image without dimensions causes the browser to render the page with 0 height for the image, then push content down when the image loads. Every pixel of push-down contributes to CLS.
In EDS block JS:
// BAD — creates image without dimensions
const img = document.createElement('img');
img.src = imageUrl;
imageWrapper.append(img);
// GOOD — preserve the EDS-generated picture element which has dimensions
const picture = block.querySelector('picture');
// The <picture> element already has correct width/height set by EDS
imageWrapper.append(picture);
EDS's createOptimizedPicture() utility generates images with correct dimensions automatically. Use it instead of creating <img> tags manually:
import { createOptimizedPicture } from '../../scripts/aem.js';
const picture = createOptimizedPicture(src, alt, false, [
{ media: '(min-width: 900px)', width: '1200' },
{ width: '600' },
]);
imageWrapper.append(picture);
// BAD — synchronous fetch blocks page rendering
export default function decorate(block) {
// This blocks the rendering pipeline
const response = fetch('/api/data'); // NOT awaited
const data = response.json(); // This will fail silently
renderData(data);
}
// ALSO BAD — heavy DOM manipulation before page is interactive
export default function decorate(block) {
// 5000 DOM operations synchronously
for (let i = 0; i < 5000; i++) {
const el = document.createElement('div');
el.textContent = `Item ${i}`;
block.append(el);
}
}
Effect on INP: Heavy synchronous work in decorate() blocks the main thread. If this happens during the interaction response window (< 200ms after a click), INP is violated.
Fix: Use async/await for data fetching. For large DOM operations, use DocumentFragment to batch DOM writes:
export default async function decorate(block) {
// Async fetch — doesn't block rendering
const data = await fetchData('/api/items');
// Batch DOM operations with DocumentFragment
const fragment = document.createDocumentFragment();
data.forEach(item => {
const el = document.createElement('div');
el.textContent = item.name;
fragment.append(el);
});
block.append(fragment); // Single DOM write
}
/* BAD — browser waits for font before rendering text */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
}
/* GOOD — browser renders with system font immediately, swaps when ready */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
font-display: swap;
}
Effect on LCP and CLS: Without font-display: swap, browsers show invisible text while the font loads (FOIT — Flash of Invisible Text). With swap, text is visible immediately with system font, then swaps to the custom font. swap can cause a small CLS if line wrapping changes, but it is still better than invisible text blocking LCP.
All fonts in EDS should use font-display: swap. The boilerplate sets this in styles/fonts.css.
// BAD — loads 1200px image on a 375px mobile screen
const img = document.createElement('img');
img.src = '/images/hero.jpg';
Effect on LCP (mobile): Loading a full-resolution image on mobile means LCP waits for a large file to download over a slow connection.
Fix: Use EDS's createOptimizedPicture() or let EDS auto-process images via the Media Bus.
EDS automatically processes images when they go through the Content Bus:
srcsetsizes attributeThis only works if the image is in the JCR/DA content, not hardcoded in JS. For dynamically loaded images, use createOptimizedPicture().
The hero block is almost always the LCP element. Optimizing it has the highest impact on overall score.
// blocks/hero/hero.js
export default function decorate(block) {
const picture = block.querySelector('picture');
// 1. Add fetchpriority="high" to the LCP image
const img = picture?.querySelector('img');
if (img) {
img.setAttribute('fetchpriority', 'high');
img.setAttribute('loading', 'eager'); // Remove lazy loading from LCP image
}
// 2. Ensure dimensions are set (prevents CLS)
// picture element from EDS already has width/height on img
// 3. Build DOM first, then append to block
const section = document.createElement('div');
section.classList.add('hero-section');
const imageWrapper = document.createElement('div');
imageWrapper.classList.add('hero-image-wrapper');
if (picture) imageWrapper.append(picture);
section.append(imageWrapper);
block.append(section);
}
Key: fetchpriority="high"
This tells the browser to download this image as the highest priority resource — before other images, before non-critical CSS, before lazy-loaded scripts. For LCP images, this can reduce LCP time by 30-50%.
Key: Remove loading="lazy" from LCP images
EDS boilerplate sometimes adds loading="lazy" to all images for performance. The LCP image should NOT be lazy-loaded — the browser should load it immediately.
For the very best LCP score, add a <link rel="preload"> in head.html or via scripts.js:
// scripts/scripts.js — preload the hero image for the current page
// Only do this for pages with a hero block
async function loadEager(doc) {
// After detecting a hero block, preload its image
const heroBlock = doc.querySelector('.hero');
if (heroBlock) {
const picture = heroBlock.querySelector('picture source[type="image/webp"]');
if (picture?.srcset) {
const preload = document.createElement('link');
preload.rel = 'preload';
preload.as = 'image';
preload.href = picture.srcset.split(' ')[0];
document.head.append(preload);
}
}
}
CLS is the hardest metric to diagnose because multiple things can cause it and the shift happens visually.
hadRecentInput flagOr use the Web Vitals extension: it highlights shifted elements in a red overlay as they shift.
Pattern 1: Unsized image
Problem: <img> without width/height shifts content down when it loads
Diagnosis: Layout Shift → shifted element is <img> or its parent
Fix: Add width/height attributes, or use CSS aspect-ratio as a placeholder
/* CSS fix: reserve space for image before it loads */
.hero-image-wrapper {
aspect-ratio: 16 / 9; /* Reserves space proportional to expected image */
overflow: hidden;
}
Pattern 2: Font swap causing text reflow
Problem: Custom font has different metrics than system fallback
Diagnosis: Layout Shift → shifted element is a text container, hadRecentInput=false
Fix: Use size-adjust and ascent-override to match fallback metrics
/* Define matching fallback font */
@font-face {
font-family: 'MyFont Fallback';
src: local('Arial');
size-adjust: 105%; /* Adjust until line lengths match */
ascent-override: 95%;
descent-override: 20%;
line-gap-override: 0%;
}
body {
font-family: 'MyFont', 'MyFont Fallback', sans-serif;
}
Pattern 3: Block changing height during JS decoration
Problem: Block renders at initial HTML height, JS adds/removes elements causing height change
Diagnosis: Layout Shift at ~100-300ms after load (JS execution time)
Fix: Reserve minimum height, or render full block before appending to DOM
// Fix: Build full DOM before appending to page
export default function decorate(block) {
// Build complete structure off-screen
const container = document.createElement('div');
container.classList.add('cards-container');
buildCards(container, block);
// Single DOM write — no intermediate height changes
block.innerHTML = '';
block.append(container);
}
INP measures how long it takes for the page to visually respond after a user interaction.
Testing INP:
Common EDS INP issues:
// BAD — click handler does synchronous heavy work
block.addEventListener('click', () => {
// Heavy operation on main thread
processThousandItems(); // > 50ms blocks INP
updateDOM();
});
// GOOD — defer heavy work
block.addEventListener('click', () => {
// Yield to browser first (shows visual feedback immediately)
requestAnimationFrame(() => {
processThousandItems();
updateDOM();
});
});
Automate performance testing on every pull request.
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v12
with:
urls: |
https://main--eds-poc--codewithnaveenrapelli.aem.page/
https://main--eds-poc--codewithnaveenrapelli.aem.page/products/
budgetPath: .lighthouse/budget.json
uploadArtifacts: true
temporaryPublicStorage: true
// .lighthouse/budget.json
[
{
"path": "/*",
"timings": [
{ "metric": "interactive", "budget": 3000 },
{ "metric": "first-contentful-paint", "budget": 1000 }
],
"lighthouse": [
{ "metric": "performance", "minScore": 0.9 },
{ "metric": "accessibility", "minScore": 0.9 },
{ "metric": "best-practices", "minScore": 0.9 },
{ "metric": "seo", "minScore": 0.9 }
]
}
]
This configuration:
Important: Test against the preview URL, not a deploy preview. Your preview URL (main--eds-poc--...aem.page) reflects the current state of the main branch. For PR testing, push your branch and test against the branch preview URL:
urls: |
https://{branch}--eds-poc--codewithnaveenrapelli.aem.page/
Before marking any block as complete, validate:
LCP:
[ ] Hero image has fetchpriority="high" and loading="eager"
[ ] LCP image is served through EDS Media Bus (auto-WebP, auto-srcset)
[ ] No render-blocking scripts in head.html
[ ] styles.css is under 14KB
[ ] Lighthouse performance score >= 90 on mobile
CLS:
[ ] All images have width and height attributes
[ ] CSS uses aspect-ratio for image containers
[ ] Fonts have font-display: swap
[ ] Block renders at final height without intermediate height changes
[ ] CLS score < 0.1
INP:
[ ] No synchronous heavy operations in decorate()
[ ] Event listeners use requestAnimationFrame for heavy DOM work
[ ] Third-party scripts in delayed.js only
[ ] INP < 200ms on key interactions
Tools used:
[ ] PageSpeed Insights run on preview URL (mobile)
[ ] Web Vitals extension checked during manual browsing
[ ] DevTools Performance profile reviewed for long tasks (> 50ms)
[ ] Lighthouse CI passing on PR (if configured)
When Lighthouse flags a performance issue, the report links directly to the offending resource or element. Here's how to read the key sections:
"Eliminate render-blocking resources"
→ You have a <script> or <link rel="stylesheet"> in <head> that is not deferred.
→ Move to delayed.js or lazy-styles.css.
"Properly size images"
→ You're loading images larger than their display size.
→ Use createOptimizedPicture() with appropriate width values.
"Serve images in next-gen formats" → Images are not WebP. → Ensure images go through EDS Media Bus (not loaded from external CDN directly).
"Avoid enormous network payloads" → Total page size is too large. → Check which resources are large: JS bundles? CSS? Images?
"Avoid large layout shifts" → Shows exactly which elements shifted and when. → Follow the CLS debugging steps above.
"Reduce unused JavaScript" → You have large JS files where only a small portion is used. → EDS loads JS per-block — only a problem if a block imports a large library.
fetchpriority="high" and loading="eager" to hero imageswidth/height, aspect-ratio, and single-DOM-write rendering patternsasync/await for fetches, requestAnimationFrame for heavy DOM operationsdelayed.jslazy-styles.css or block CSSThe next chapter, Security in EDS Blocks, moves from performance to the other production concern you own entirely in EDS. In AEM, much of the security baseline came from the platform. In EDS, every block that touches user input, external APIs, or injected HTML is your responsibility — XSS, credential handling, CORS, and what belongs server-side instead of in browser JavaScript.
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.