N
Naveenr.dev
Chapter 13
27 min read2026-06-30

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:

ScoreLCP
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:

ScoreCLS
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 width and height attributes (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:

ScoreINP
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 with type="module" which is deferred by default)
  • Critical CSS in styles.css is 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.js phase (1.3s+ after page load)
  • No synchronous scripts added by developers

The guarantee breaks when you:

  1. Add <script> tags to head.html
  2. Add sync operations in the eager loading phase (scripts.js before loadEager completes)
  3. Load large CSS files eagerly
  4. Add images without width and height attributes
  5. Load third-party scripts before the delayed.js phase

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:

  1. The homepage
  2. A page with your most complex block (hero, cards, carousel)
  3. 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:

  1. Open your .aem.page URL in Chrome
  2. DevTools → Lighthouse → Generate report
  3. 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.

  1. DevTools → Performance → Record
  2. Reload the page
  3. Stop recording
  4. Look at the "Timings" row: DOMContentLoaded, LCP, FCP marks
  5. 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:

  1. Install the extension
  2. Open your .aem.page URL
  3. Watch the LCP value as the page loads
  4. Scroll slowly — watch CLS accumulate
  5. 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:

  1. Your URL has at least 1,000 page views in the last 28 days
  2. 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 sizes attribute

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

  1. Open Chrome DevTools
  2. Performance tab → Record
  3. Reload page, let it fully load, then scroll
  4. Stop recording
  5. In the "Experience" row, look for red blocks labeled "Layout Shift"
  6. Click a Layout Shift block → "Summary" shows the shifted element and its hadRecentInput flag

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:

  1. Web Vitals extension shows live INP as you click
  2. DevTools → Performance → "Interactions" row shows each interaction
  3. 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" and loading="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/await for fetches, requestAnimationFrame for 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.css or 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:

  1. What is EDS — architecture, fundamentals, page flow
  2. Setup — GitHub, AEM, fstab, aem up
  3. Blocks — anatomy, conventions, variants, key-value blocks
  4. Folder Structure — every file explained, loading phases
  5. Common Issues — systematic debugging, 7 real bugs
  6. EDS Capabilities — DA, UE, experimentation, Query API, multi-locale, multi-brand
  7. EDS vs AEM — 10 AEM features mapped to EDS equivalents
  8. Content Storage Architecture — JCR vs Content Bus, xwalk delivery flow
  9. What is Not Feasible — 10 limitations with alternatives, decision framework
  10. CSS Architecture — tokens, cascade, BEM replacement, responsive, theming
  11. UE Annotationsdata-aue-* attributes, in-context editing, editor-support.js
  12. DA vs UE — authoring experience comparison, decision framework
  13. 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.