Performance in EDS
Measuring and validating EDS performance. Covers Core Web Vitals, Lighthouse, PageSpeed Insights, LCP, CLS, INP, common regressions, and Lighthouse CI.
Content Objective
This chapter covers:
- The three Core Web Vitals and the target numbers EDS aims for
- How to run Lighthouse and PageSpeed Insights against your EDS page
- The difference between lab data (Lighthouse) and field data (CrUX)
- What the "100 LHS guarantee" actually means and how to keep it
- The most common EDS-specific performance killers and how to fix each
- How to identify which element is your LCP and optimize it
- How to debug Cumulative Layout Shift (CLS) — the hardest metric to fix
- How to measure Interaction to Next Paint (INP)
- Lighthouse CI in GitHub Actions — automated performance gates
- The Web Vitals browser extension for real-time measurement
Why Performance Matters in EDS
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.
The Three Core Web Vitals
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.
1. Largest Contentful Paint (LCP)
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:
- The hero block's
<img>or<picture>element - The hero's
<h1>heading if no large image is above the fold - A full-width banner image
Why EDS optimizes LCP well:
- Images are served through the EDS Media Bus as WebP
- Automatic responsive images (
srcset) reduce file size for each viewport - No render-blocking JS in the eager loading phase
- No server-side rendering delay — HTML is pre-cached at edge
2. Cumulative Layout Shift (CLS)
What 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:
- Images without
widthandheightattributes (browser can't reserve space) - Fonts loading after layout causes text reflow
- Blocks that render different heights during JS loading
- Late-loading elements that push down existing content
3. Interaction to Next Paint (INP)
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:
- Heavy synchronous JavaScript in
decorate()blocking the main thread - Third-party analytics or chat widgets running heavy scripts during interaction
- Large DOM trees with complex event listeners
The EDS Performance Guarantee — What It Actually Means
EDS guarantees that the boilerplate, correctly used, will achieve a 100/100 Lighthouse score. This means:
- No render-blocking resources in
<head>(EDS loads all JS as modules withtype="module"which is deferred by default) - Critical CSS in
styles.cssis minimal (< 14KB is a practical target) - Images served as WebP from Media Bus
- Fonts loaded in the lazy phase with
font-display: swap - Third-party scripts deferred to
delayed.jsphase (1.3s+ after page load) - No synchronous scripts added by developers
The guarantee breaks when you:
- Add
<script>tags tohead.html - Add sync operations in the eager loading phase (
scripts.jsbeforeloadEagercompletes) - Load large CSS files eagerly
- Add images without
widthandheightattributes - Load third-party scripts before the
delayed.jsphase
Measurement Tools
Tool 1: PageSpeed Insights
URL: https://pagespeed.web.dev
What it gives you:
- Lab data (simulated Lighthouse score)
- Field data (real user CrUX data — only available if the page has real traffic)
- Both mobile and desktop scores
- Specific recommendations linked to your actual page
How to use it with EDS:
Test against your preview URL (.aem.page) not localhost, because:
- Localhost serves JS/CSS from your disk but HTML from preview — mixed environment
- Preview URL runs the full EDS stack (CDN, Media Bus, WebP delivery)
- PageSpeed Insights measures what real users experience
https://pagespeed.web.dev/report?url=https://main--eds-poc--codewithnaveenrapelli.aem.page/
Run it on:
- The homepage
- A page with your most complex block (hero, cards, carousel)
- A content-heavy article page
Tool 2: Chrome Lighthouse (DevTools)
How to open: DevTools → "Lighthouse" tab
Advantages over PageSpeed Insights:
- You control the exact state of the page (logged in, specific cookies)
- You can test localhost with caveats
- Detailed trace view showing exactly what is slow
Settings for EDS testing:
- Mode: Navigation
- Device: Mobile (this is where EDS is most impactful — mobile scores are harder)
- Categories: Performance (uncheck others unless you need them)
How to test against preview:
- Open your
.aem.pageURL in Chrome - DevTools → Lighthouse → Generate report
- Do not test in a tab with extensions — extensions inject scripts that inflate measurements
Tool 3: Chrome DevTools Performance Tab
For deep diagnosis of what is causing slow LCP or INP.
- DevTools → Performance → Record
- Reload the page
- Stop recording
- Look at the "Timings" row: DOMContentLoaded, LCP, FCP marks
- Look at the "Main" flame chart: identify long tasks (> 50ms) that block the main thread
Finding the LCP element: In the Performance recording, look for the "LCP" marker in the Timings row. Click it. The tooltip shows:
- LCP element (what HTML element caused it)
- Time to LCP
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.
Tool 4: Web Vitals Chrome Extension
Install from: Chrome Web Store → "Web Vitals"
Shows a live CWV badge for any page you visit. While the page loads, the extension displays:
- LCP value (updates as page loads)
- CLS value (accumulates as you scroll)
- INP value (updates as you interact)
Green badge = Good. Yellow = Needs improvement. Red = Poor.
How to use during EDS development:
- Install the extension
- Open your
.aem.pageURL - Watch the LCP value as the page loads
- Scroll slowly — watch CLS accumulate
- Click buttons, navigate — watch INP
This is the fastest feedback loop for performance during active development.
Tool 5: CrUX Dashboard (Field Data)
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:
- Your URL has at least 1,000 page views in the last 28 days
- Google has collected enough data
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.
Common EDS Performance Killers
Killer 1: Scripts in head.html
<!-- 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');
Killer 2: Large CSS in styles.css
/* 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 */
Killer 3: Images Without Dimensions
<!-- 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);
Killer 4: Synchronous Operations in decorate()
// 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
}
Killer 5: Missing font-display: swap
/* 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.
Killer 6: Not Using Responsive Images
// 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:
- Converts to WebP
- Creates responsive sizes based on
srcset - Generates appropriate
sizesattribute
This only works if the image is in the JCR/DA content, not hardcoded in JS. For dynamically loaded images, use createOptimizedPicture().
Optimizing LCP: The Hero Block
The hero block is almost always the LCP element. Optimizing it has the highest impact on overall score.
LCP Optimization Checklist for Hero
// 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.
Preloading the LCP Image
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);
}
}
}
Debugging CLS
CLS is the hardest metric to diagnose because multiple things can cause it and the shift happens visually.
The Layout Shift Tool
- Open Chrome DevTools
- Performance tab → Record
- Reload page, let it fully load, then scroll
- Stop recording
- In the "Experience" row, look for red blocks labeled "Layout Shift"
- Click a Layout Shift block → "Summary" shows the shifted element and its
hadRecentInputflag
Or use the Web Vitals extension: it highlights shifted elements in a red overlay as they shift.
Finding and Fixing Common CLS Sources
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);
}
Debugging INP
INP measures how long it takes for the page to visually respond after a user interaction.
Testing INP:
- Web Vitals extension shows live INP as you click
- DevTools → Performance → "Interactions" row shows each interaction
- Long interactions appear as orange/red bars
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();
});
});
Lighthouse CI in GitHub Actions
Automate performance testing on every pull request.
Setup
# .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
Performance Budget
// .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:
- Runs Lighthouse on every PR against your preview URL
- Fails the PR if performance score drops below 90
- Stores the report as a PR artifact for review
- Requires no extra infrastructure (runs in GitHub Actions)
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/
Performance Validation Checklist
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)
Reading a Lighthouse Report
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.
Key Takeaways
- Three CWV metrics: LCP < 2.5s, CLS < 0.1, INP < 200ms — these are the targets
- PageSpeed Insights on preview URL — most representative of real user experience, do this first
- Web Vitals extension — fastest feedback loop during active development
- LCP is usually the hero image — add
fetchpriority="high"andloading="eager"to hero images - CLS is caused by unsized images, font swap, and JS changing block height — fix with
width/height,aspect-ratio, and single-DOM-write rendering patterns - INP is caused by heavy synchronous work — use
async/awaitfor fetches,requestAnimationFramefor heavy DOM operations - Never put scripts in head.html — all third-party code belongs in
delayed.js - styles.css must stay lean — < 14KB; non-critical CSS to
lazy-styles.cssor block CSS - Lighthouse CI on GitHub Actions — automated performance gate prevents regressions reaching main
Next Steps
This chapter completes the EDS series' performance coverage. The full 13-chapter series now covers:
- What is EDS — architecture, fundamentals, page flow
- Setup — GitHub, AEM, fstab, aem up
- Blocks — anatomy, conventions, variants, key-value blocks
- Folder Structure — every file explained, loading phases
- Common Issues — systematic debugging, 7 real bugs
- EDS Capabilities — DA, UE, experimentation, Query API, multi-locale, multi-brand
- EDS vs AEM — 10 AEM features mapped to EDS equivalents
- Content Storage Architecture — JCR vs Content Bus, xwalk delivery flow
- What is Not Feasible — 10 limitations with alternatives, decision framework
- CSS Architecture — tokens, cascade, BEM replacement, responsive, theming
- UE Annotations —
data-aue-*attributes, in-context editing, editor-support.js - DA vs UE — authoring experience comparison, decision framework
- Performance — CWV metrics, Lighthouse, LCP/CLS/INP debugging, CI gates
For the next phase: see Chapter 09 — Architecture Decision Framework for guidance on whether EDS fits your project's requirements.
Enjoyed this chapter?
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.