Magento INP and CLS: Fixing the Two Hard Vitals

lead

LCP is the vital you can fix with infrastructure. Magento INP and CLS are the two you cannot, and that is what makes them the hard half of this work. Both describe what happens after the page has loaded — how long the store takes to respond when someone taps add-to-cart, and whether anything moves under their finger while they are reaching for it. Neither shows up reliably in a lab test, because a lab test does not tap anything and does not wait for the lazy-loaded banner that arrives at second four and pushes the whole page down.

The practical consequence: you cannot fix these from a Lighthouse score. You need field data, or at minimum a browser session where you actually interact with the page the way a customer does. Everything below assumes you are willing to do that.

This is one of four deep dives under Magento 2 Core Web Vitals: From Fails to Passes, which carries the metrics, the level ordering and the cost-benefit framing.

The single most misleading thing about INP: Lighthouse does not measure it. Lighthouse reports Total Blocking Time, a lab proxy, and a store can have excellent TBT and failing field INP because the expensive interaction is add-to-cart and nothing in the lab ever clicks it. If your INP is red in Search Console and green in Lighthouse, Search Console is right.


baseline: what INP measures, and why lab tools miss it

👥 For: Frontend, Backend (observer chains)

⏱ TL;DR: Profile INP → Defer observers + optimize JS (1–2 weeks) = 100–200ms INP improvement. Hardest but highest conversion impact.

A. What INP Is (and Why It Matters More Than You Think)

INP = Interaction to Next Paint

  • Time from a user interaction (click, tap, keypress) until the browser paints the result
  • Measured in milliseconds, covering input delay + JavaScript execution + rendering
  • Reported for the whole visit, not for one interaction: INP is effectively the worst interaction on the page, so a single slow add-to-cart sets it however fast everything else is
  • Target: ≤200ms (good), ≤500ms (needs improvement)

Why INP matters:

  • Users notice every interaction delay >100ms (feels sluggish)
  • INP failures often stem from Magento’s observer chains, jQuery overhead, and extension conflicts
  • Mobile INP is 2–3x worse than desktop (less CPU power)
  • Fixing INP improves perceived performance more than any other metric

Real-world impact (Luma vs Hyvä):

Luma baseline:
├─ Add-to-cart click → 380ms (fails)
├─ Search typeahead keystroke → 250ms (fails)
└─ Quick view modal open → 320ms (fails)

Hyvä (optimized):
├─ Add-to-cart click → 120ms (passes)
├─ Search typeahead → 85ms (passes)
└─ Quick view → 95ms (passes)

Common INP blocking (Magento specifics):

Timeline of add-to-cart interaction:
0ms    User clicks "Add to Cart" button
       ↓
10ms   JavaScript event handler fires
       ↓
15ms   Magento observer chain starts:
       ├─ catalog_product_view_config_observer (10ms)
       ├─ cart_add_complete_observer (30ms)
       ├─ inventory_deduction (40ms)
       └─ shipping_calculation (80ms) ← LONG TASK!
       ↓
175ms  DOM update (cart total, mini cart refresh)
       ↓
200ms  Paint event (browser renders update)
       
Total INP: 200ms ✓ PASSES (barely!)

But if any observer takes longer → INP fails →
380ms: User sees "Add to Cart" hang for 0.4s

B. Measuring INP (DevTools + RUM)

Method 1: Chrome DevTools Performance Tab

# 1. Open DevTools (F12)
# 2. Go to Performance tab
# 3. Click Record (red circle)
# 4. Perform interaction (click "Add to Cart")
# 5. Wait 3 seconds, click Stop
# 6. View results

What to look for in the timeline:

Performance Recording:
├─ 0ms: Interaction event fires
├─ 10ms: JavaScript execution starts
│  ├─ parseUrl() [2ms]
│  ├─ validateCart() [8ms]
│  └─ attachHandlers() [15ms]
├─ 50ms: Layout recalculation (browser measures DOM)
├─ 100ms: Paint (browser renders pixels)
└─ 200ms: Interaction completes

This interaction: 200ms from click to paint

Identify long tasks:

  • Red bar in timeline = long task (>50ms)
  • Mouse over it to see function name
  • Example: observer running 80ms is a bottleneck

Method 2: Lighthouse (synthetic test)

# Run Lighthouse performance audit
# Chrome DevTools → Lighthouse tab
# Run "Performance" audit
# Scroll to "Interactions" section

# Output:
# INP (Interaction to Next Paint): 185ms ✓ PASS
# Longest interaction: 205ms (Add to Cart click)
#   └─ Breakdown: JS execution 145ms, rendering 60ms

Method 3: PerformanceObserver (measure real users)

Add to your frontend:

// Capture all interactions
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.interactionId) {
      console.log({
        name: entry.name,
        duration: entry.duration,
        interactionId: entry.interactionId
      });
      
      // Send to analytics/monitoring
      window.sendMetric({
        type: 'INP',
        value: entry.duration,
        interaction: entry.name
      });
    }
  }
});

observer.observe({ entryTypes: ['event', 'first-input', 'longest-input'] });

Method 4: Web Vitals API (official, simplest)

// npm: web-vitals
import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log(`INP: ${metric.value}ms`);
  // Send to analytics
  gtag('event', 'web_vitals', {
    metric_id: metric.id,
    value: Math.round(metric.value),
    event_label: metric.name
  });
});

C. Implementation Owner: Who Optimizes INP

TaskOwnerEffortWhy This Owner
INP profiling (DevTools)Frontend2–4 hoursBrowser tools, interaction testing
Observer chain auditBackend2–4 hoursCode review, Magento expertise
Move observers to async queueBackend1–2 weeksMessageQueue integration, testing
Typeahead debouncingFrontend2–4 daysJavaScript optimization
Modal/drawer performanceFrontend1–2 weeksLazy rendering, code-splitting
Web Worker implementationFrontend1–2 weeksParallel processing setup
Long task detectionFrontend + DevOps2 hoursMonitoring setup, alerting

Coordination: Frontend identifies slow interactions → Backend optimizes server-side → Frontend optimizes JS

D. INP Culprits & Fixes (Magento Specific)

1. Observer Chain Blocking (BIGGEST CULPRIT)

The problem:

<!-- etc/frontend/events.xml -->
<event name="checkout_cart_add_product_complete">
  <observer name="inventory_observer" instance="Vendor\Module\Observer\InventoryObserver" />
  <observer name="shipping_observer" instance="Vendor\Module\Observer\ShippingObserver" />
  <observer name="analytics_observer" instance="Vendor\Module\Observer\AnalyticsObserver" />
  <!-- Each observer runs SYNCHRONOUSLY on main thread -->
  <!-- If total > 200ms, INP fails -->
</event>

The fix: Move observers to ASYNC (queue)

<!-- etc/frontend/events.xml -->
<event name="checkout_cart_add_product_complete">
  <!-- Sync observers: only critical ones -->
  <observer name="cart_total_update" instance="Vendor\Module\Observer\UpdateCartTotal" />
  
  <!-- Async observers: defer heavy work -->
  <observer name="inventory_async" instance="Vendor\Module\Observer\InventoryAsync" shared="false" />
  <observer name="shipping_async" instance="Vendor\Module\Observer\ShippingAsync" shared="false" />
  <observer name="analytics_async" instance="Vendor\Module\Observer\AnalyticsAsync" shared="false" />
</event>

Then in PHP:

// Async observer: defer work to queue
class InventoryAsync implements ObserverInterface
{
  public function __construct(
    private MessageQueue $messageQueue
  ) {}

  public function execute(Observer $observer)
  {
    $product = $observer->getEvent()->getProduct();
    
    // Don't run now, queue it
    $this->messageQueue->publish('inventory.update', [
      'product_id' => $product->getId(),
      'quantity' => $this->getNewQuantity($product)
    ]);
    
    return $this;
  }
}

// Message handler (runs async, doesn't block INP)
class InventoryUpdateHandler
{
  public function execute(array $data)
  {
    // Heavy work here (DB queries, ES updates, etc.)
    $this->updateInventory($data['product_id'], $data['quantity']);
  }
}

Impact: Observer chain 80ms → async queue → INP 120ms ✓ PASSES

2. Search Typeahead (Unbounded Queries)

The problem:

// BAD: Fires query on every keystroke
$('#search').on('keyup', function(e) {
  const query = $(this).val();
  
  // User types "iphone" (6 characters)
  // Fires 6 queries in 200ms → 200ms INP each
  $.get('/search/suggest?q=' + query, (results) => {
    renderSuggestions(results);  // DOM update = 100ms INP
  });
});

The fix: Debounce + minimal DOM updates

// GOOD: Debounce + cache
const debounce = (fn, delay) => {
  let timeout;
  return function(...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => fn.apply(this, args), delay);
  };
};

$('#search').on('keyup', debounce(function(e) {
  const query = $(this).val();
  
  // Only fires after user stops typing for 300ms
  if (query.length < 2) return;
  
  // Check cache first
  if (cache[query]) {
    renderSuggestions(cache[query]);  // 20ms (no server call)
    return;
  }
  
  // Fetch only if not cached
  $.get('/search/suggest?q=' + query, (results) => {
    cache[query] = results;  // Remember for next keystroke
    renderSuggestions(results);  // 50ms INP
  });
}, 300));

Impact: Unbounded queries 250ms INP → debounced + cached 85ms INP ✓ PASSES

3. Modal/Drawer Performance (Quick View)

The problem:

// BAD: Render entire product on main thread
$('.quick-view').on('click', function(e) {
  e.preventDefault();
  const productId = $(this).data('product-id');
  
  // Fetch HTML template + data
  $.get('/product/quickview?id=' + productId, (html) => {
    // Render 300KB of HTML (images, specs, reviews, etc.)
    $('#modal').html(html);  // INP 280ms
    
    // Initialize plugins
    initCarousel($('#modal .carousel'));  // 50ms
    initZoom($('#modal .zoom'));  // 40ms
  });
});

The fix: Lazy render + progressive enhancement

// GOOD: Render only visible content first
$('.quick-view').on('click', function(e) {
  e.preventDefault();
  const productId = $(this).data('product-id');
  
  // Show skeleton immediately (no fetch needed)
  showModalSkeleton();  // 10ms (just CSS)
  
  // Fetch minimal data (JSON, not HTML)
  $.get('/api/product/quickview?id=' + productId, (data) => {
    // Render only above-the-fold (20 lines, not 300)
    renderModalHeader(data);  // 30ms INP
    
    // Defer below-the-fold
    setTimeout(() => {
      renderModalDetails(data);  // 100ms (non-blocking)
      renderReviews(data);  // 50ms (non-blocking)
    }, 0);  // Yield to browser
    
    // Initialize only visible carousel
    initCarousel($('#modal .carousel'));  // 20ms
  });
});

Impact: Full render 320ms INP → lazy render 50ms INP ✓ PASSES

D. Long Tasks Detection & Breakdown

What is a long task?

  • Any JavaScript execution >50ms blocks user interaction
  • Examples: complex loop, large JSON parse, DOM queries
  • Detection: Chrome DevTools → Performance tab (red bars)

Find long tasks programmatically:

// PerformanceLongTaskTiming API
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`Long task detected: ${entry.duration}ms`);
    console.log(`  Attribution:`, entry.attribution);  // Which script caused it
    
    // Send to monitoring
    sendAlert({
      type: 'LONG_TASK',
      duration: entry.duration,
      script: entry.attribution[0]?.name || 'unknown'
    });
  }
});

observer.observe({ entryTypes: ['longtask'] });

Real example from Magento:

Long Task Detected: 145ms
Attribution: Magento/Catalog/view/frontend/web/js/product-list.js

Breakdown:
├─ Filter application (35ms)
│  └─ Loop through 5000 products, check filter criteria
├─ Template rendering (60ms)
│  └─ Handlebars.js compiling template for 100 products
├─ DOM insertion (40ms)
│  └─ jQuery appending 100 elements to page
└─ Total: 145ms

Fix: Break into chunks with setTimeout:

Fix (chunk the work):

// BAD: Everything on main thread
function filterAndRender(products, filters) {
  const filtered = products.filter(p => matchesFilters(p, filters));  // 35ms
  const html = template.render(filtered);  // 60ms
  $('#results').html(html);  // 40ms
  // Total: 145ms BLOCKS INTERACTION
}

// GOOD: Yield to browser between chunks
async function filterAndRenderAsync(products, filters) {
  // Chunk 1: Filter (35ms)
  const filtered = products.filter(p => matchesFilters(p, filters));
  yield();  // Yield to browser
  
  // Chunk 2: Render (60ms)
  const html = template.render(filtered);
  yield();  // Yield again
  
  // Chunk 3: Insert (40ms)
  $('#results').html(html);
  yield();  // Final yield
}

// Call it
filterAndRenderAsync(products, filters);

Or simpler (using setTimeout):

function filterAndRenderChunked(products, filters) {
  const filtered = products.filter(p => matchesFilters(p, filters));
  
  setTimeout(() => {
    const html = template.render(filtered);
    
    setTimeout(() => {
      $('#results').html(html);
    }, 0);
  }, 0);
}
// Each task <50ms, INP unblocked

E. Web Workers for Heavy Computation

When to use Web Workers:

  • JSON parsing of large datasets (>1MB)
  • Image processing (resizing, filtering)
  • Complex calculations (recommendations, sorting)
  • NOT for DOM manipulation (workers can’t access DOM)

Example: Parse large product catalog JSON

// Main thread (keep light)
fetch('/api/catalog?limit=10000')
  .then(r => r.json())  // Would block INP if >100ms
  .then(data => {
    // Offload parsing to worker
    const worker = new Worker('/js/catalog-worker.js');
    worker.postMessage({ products: data });
    
    worker.onmessage = (e) => {
      // Worker returns parsed/sorted data
      renderCatalog(e.data.sorted);  // 50ms INP (data already parsed)
    };
  });

// catalog-worker.js (runs in background, doesn't block INP)
self.onmessage = (e) => {
  const { products } = e.data;
  
  // Heavy work here (100–300ms)
  const sorted = products
    .map(p => ({ ...p, price: parseFloat(p.price) }))
    .sort((a, b) => a.price - b.price);
  
  self.postMessage({ sorted });  // Send back to main thread
};

Impact: Blocking parse 150ms → Web Worker 50ms INP ✓ PASSES

F. INP Checklist

  • [ ] Profiled with Chrome DevTools Performance tab (identify long tasks)
  • [ ] No observer >50ms (async queue for heavy observers)
  • [ ] Search typeahead debounced (300ms minimum)
  • [ ] Modals render progressively (skeleton first, content lazy)
  • [ ] No large JSON parsing on main thread (Web Worker if >100ms)
  • [ ] No N+1 loops (e.g., querying product details 5000 times)
  • [ ] jQuery removed from critical path (or Alpine.js used)
  • [ ] Analytics tracking deferred (async, not inline)
  • [ ] No synchronous localStorage/cookie reads during interactions
  • [ ] INP score <200ms verified via Lighthouse (mobile)

Result: Well-optimized Magento site reaches INP <200ms (passes Core Web Vitals)


Layout stability: what actually shifts, and why

👥 For: Frontend, Content team

⏱ TL;DR: Add image dimensions (2–4 days) + reserve space (1–2 days) + async images (1–2 weeks) = CLS <0.1. Easiest metric to fix, impacts UX.

Why CLS Matters (Beyond Core Web Vitals)

High CLS isn’t just a metric — it causes real user errors and revenue loss.

The CLS problem chain:
1. User sees "Add to Cart" button at position X
2. Ads/images load, button shifts to position Y
3. User clicks where button WAS, but it moved
4. User accidentally clicks wrong element (wishlist, reviews, etc.)
5. User gets confused, abandons cart
└─ Lost sale due to layout shift

Real data:
├─ CLS 0.1–0.25: +3–5% accidental clicks
├─ CLS 0.25–0.40: +8–15% accidental clicks
└─ CLS >0.40: +20%+ abandon rate (user gives up)

Business impact:
├─ Typical store 100k monthly visitors
├─ CLS 0.40 (high) = 8–12% higher bounce rate
├─ 8% bounce = 8000 lost sessions/month
├─ Avg order value $50 = $400k lost revenue/month!
└─ CLS 0.05 (optimized) = nearly zero accidental clicks

A. What CLS Is (and Why It Ruins UX)

CLS = Cumulative Layout Shift

  • Visual instability: content moving after it has already been painted
  • Each shift scores impact fraction × distance fraction. CLS is the largest burst of those scores inside a session window — at most 5 seconds long, with no more than 1 second between shifts — not the total for the whole page load
  • Unitless, and not capped at 1: a badly shifting page reports 2 or more
  • Target: ≤0.1 (good), ≤0.25 (needs improvement)

Why CLS matters (UX perspective):

  • User clicks "Add to Cart" button, then ads load and button moves → clicks wrong element (accidental)
  • Text shifts as fonts load → jarring, distracting reading experience (poor UX)
  • Images load late → pushes content down, breaks layout (frustrating)
  • High CLS correlates with user error, task abandonment, and lost sales (business impact)

Real-world CLS timeline (typical product page):

0ms    Page starts loading
       └─ Layout is clean, no shifts
       
500ms  Hero image loads
       └─ Product image was placeholder (wrong aspect ratio)
       └─ shift score 0.15
       
800ms  Price block loads (price previously hidden)
       └─ Price appears, pushes "Add to Cart" button down 50px
       └─ shift score 0.08 (window total: 0.23)
       
1200ms Web fonts load
       └─ Text reflows from system font to Helvetica
       └─ shift score 0.05 (window total: 0.28) ✗ FAILS
       
1500ms Ad network loads
       └─ Ad appears, shifts entire sidebar right 30px
       └─ shift score 0.12 (window total: 0.40) ✗✗ SEVERE

Final CLS: 0.40 (fails badly)

All four shifts land in one session window - they span 1.5s with gaps
under a second - so the window total is the page's CLS. Spread the same
four across a slower load and CLS is the worst window, not the sum.

Vs optimized (reserves space):

0ms    Page starts loading
       └─ Skeleton/placeholder for all content
       
500ms  Hero image loads
       └─ Reserved space already allocated
       └─ CLS +0.00 (no shift!)
       
800ms  Price block loads
       └─ Reserved space already allocated
       └─ CLS +0.00
       
1200ms Web fonts load
       └─ Reserved space, font-display: swap
       └─ CLS +0.01 (tiny shift, acceptable)
       
1500ms Ad network loads
       └─ Ad container reserved space
       └─ CLS +0.00

Final CLS: 0.01 (passes!)

B. Measuring CLS

Method 1: Chrome DevTools Performance Tab

# 1. Open DevTools (F12)
# 2. Go to Performance tab
# 3. Record page load
# 4. Look for "Layout Shift" events (blue bars)
# 5. Total = CLS

What to look for:

Performance Timeline:
├─ 0–500ms: No shifts (baseline)
├─ 500ms: "Layout Shift" +0.15
│  └─ Mouse over → shows which element shifted (product image)
├─ 800ms: "Layout Shift" +0.08
│  └─ Price/add-to-cart button
└─ Total CLS: 0.23

Method 2: Lighthouse (synthetic)

# Chrome DevTools → Lighthouse → Run Audit

# Output includes:
CLS (Cumulative Layout Shift): 0.18 ✓ PASS
Largest shift: product image (0.15)
Second shift: price block (0.08)

Method 3: PerformanceObserver (real users)

// Measure layout shifts from real users
let cls = 0;

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {  // Ignore shifts caused by user interaction
      cls += entry.value;
      
      console.log(`Layout shift: ${entry.value} (cumulative: ${cls})`);
      console.log(`  Element:`, entry.sources[0].node);
      
      // Send to analytics
      gtag('event', 'web_vitals', {
        metric_id: 'cls',
        value: cls,
        element: entry.sources[0].node?.className
      });
    }
  }
});

observer.observe({ entryTypes: ['layout-shift'] });

Method 4: Web Vitals API (simplest)

import { onCLS } from 'web-vitals';

onCLS((metric) => {
  console.log(`CLS: ${metric.value}`);  // 0.18 (good) or 0.35 (bad)
  
  // Send to analytics
  gtag('event', 'CLS', { value: metric.value });
});

C. CLS Culprits & Fixes

1. Images Without Dimensions (BIGGEST CULPRIT)

The problem:

<!-- BAD: Browser doesn't know image height -->
<img src="/product.jpg" alt="Product" />

<!-- During load:
   1. HTML parsed (no height known)
   2. Image downloads (1000x800)
   3. Browser calculates height, reflows page
   4. CLS +0.20 (huge shift!)
-->

The fix: Always specify width & height (or aspect-ratio)

<!-- GOOD: Browser reserves space before image loads -->
<img 
  src="/product.jpg" 
  alt="Product"
  width="1000"
  height="800"
  loading="lazy"
/>

<!-- OR use CSS aspect ratio (modern) -->
<img 
  src="/product.jpg" 
  alt="Product"
  style="aspect-ratio: 1000 / 800;"
/>

<!-- OR use picture element with aspect ratio -->
<picture>
  <source srcset="/product-1000w.webp" media="(min-width: 1000px)" />
  <img 
    src="/product.jpg" 
    alt="Product"
    style="aspect-ratio: 4 / 3; object-fit: cover;"
  />
</picture>

Impact: Unspecified dimensions CLS 0.20 → reserved space CLS 0.00 ✓ PASSES

Real Magento example:

<!-- app/design/frontend/Vendor/Theme/Magento_Catalog/templates/product/view/gallery.phtml -->

<?php foreach ($images as $image): ?>
  <?php 
    $imageData = $image->getData();
    $width = $imageData['width'] ?? 1000;
    $height = $imageData['height'] ?? 1000;
  ?>
  
  <img
    src="<?= /* @noEscape */ $image->getImageUrl() ?>"
    alt="<?= $block->escapeHtmlAttr($product->getName()) ?>"
    width="<?= $width ?>"
    height="<?= $height ?>"
    class="product-image"
    loading="lazy"
  />
<?php endforeach; ?>

2. Late-Loading Content (Ads, Analytics, Notifications)

The problem:

<!-- Sidebar initially has no height -->
<aside id="sidebar" style="width: 300px;">
  <!-- Empty until ads load -->
</aside>

<!-- After 1.2s, ad network loads -->
<!-- Ads appear, push main content right -->
<!-- CLS +0.25 (entire page shifts right!) -->

The fix: Reserve space with placeholder

<!-- Reserve space BEFORE ads load -->
<aside id="sidebar" style="width: 300px; min-height: 600px;">
  <!-- Placeholder shows ad skeleton -->
  <div class="ad-placeholder" style="height: 250px; background: #f0f0f0; margin: 10px 0;">
    <p style="padding: 20px; color: #999;">Loading ads...</p>
  </div>
  
  <!-- Ad network script replaces placeholder when ready -->
  <div id="ad-container"></div>
</aside>

<script>
  // After ads load, replace placeholder with real ad
  window.addEventListener('adloaded', () => {
    const placeholder = document.querySelector('.ad-placeholder');
    if (placeholder) {
      placeholder.remove();  // Remove placeholder (same height already reserved)
    }
  });
</script>

Impact: No reserved space CLS 0.25 → reserved CLS 0.00 ✓ PASSES

3. Font Loading Shifts

The problem:

/* System font (25px) vs Web font (26px) = layout shift */
body { font-family: 'Custom Font'; }

/* During load:
   1. System font renders (25px line-height)
   2. Web font arrives
   3. Custom font renders (26px line-height)
   4. Text reflows (more line breaks)
   5. CLS +0.08 (content moves down)
*/

The fix: Use font-display: swap + system font fallback

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-400.woff2') format('woff2');
  font-weight: 400;
  font-display: swap;  /* Show system font while loading */
}

body {
  /* Fallback stack ensures consistent line-height */
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  line-height: 1.5;
}

Also match font metrics:

/* If custom font is narrower/taller, adjust fallback */
@supports (font-synthesis: none) {
  body {
    font-synthesis: none;  /* Prevent synthetic bold/italic */
    font-family: 'Inter', 'Segoe UI', sans-serif;
    font-size-adjust: 0.95;  /* Adjust system font size to match custom */
  }
}

Impact: Font swap shift CLS 0.08 → minimal shift CLS 0.01 ✓ PASSES

4. Dynamic Content Injection (Notifications, Banners)

The problem:

// Notification appears after 2 seconds, pushes entire page down
setTimeout(() => {
  const notification = document.createElement('div');
  notification.innerHTML = 'Your cart was updated!';
  notification.style.cssText = 'height: 50px; background: green;';
  document.body.insertBefore(notification, document.body.firstChild);
  // CLS +0.12 (page pushed down 50px)
}, 2000);

The fix: Reserve space in fixed position

// Notification appears in fixed position (doesn't shift page)
setTimeout(() => {
  const notification = document.createElement('div');
  notification.innerHTML = 'Your cart was updated!';
  notification.style.cssText = `
    position: fixed;
    top: 20px;
    right: 20px;
    width: 300px;
    height: 50px;
    background: green;
    z-index: 999;
  `;
  document.body.appendChild(notification);
  // CLS +0.00 (fixed position, doesn't affect layout)
  
  // Auto-dismiss after 3 seconds
  setTimeout(() => notification.remove(), 3000);
}, 2000);

D. Implementation Owner: Who Fixes CLS

TaskOwnerEffortWhy This Owner
CLS profiling (DevTools)Frontend1–2 hoursBrowser tools, visual inspection
Image dimension requirementsFrontend + Content1–2 daysTemplate specs, content guidelines
Template updates (width/height)Frontend2–4 daysPhtml/Vue updates, Magento templates
Reserved space CSSFrontend1–2 daysCSS placeholders, skeleton screens
Async image conversion queueBackend + DevOps1–2 weeksMessageQueue setup, image processing
Ad container setupFrontend + Marketing1–2 daysFixed positioning, placeholder sizing
Font loading optimizationFrontend1–2 daysfont-display property, fallbacks
Testing (visual regression)Frontend + QA2–4 hoursCross-device visual testing

Coordination: Frontend defines requirements → Content/Marketing implements → QA validates

working_example: a queued image pipeline that stopped the shifting

Context: BroCode’s image optimizer module converts product images to AVIF/WebP on-the-fly. To avoid blocking page load, conversion happens asynchronously via message queue.

Problem without queue:

// BAD: Blocks product page load (400ms)
public function convertProductImage($productId)
{
  $product = $this->productRepository->getById($productId);
  $image = $product->getData('image');
  
  // Convert JPEG → AVIF (200ms)
  $this->imageProcessor->convert($image, 'avif');
  
  // Convert JPEG → WebP (200ms)
  $this->imageProcessor->convert($image, 'webp');
  
  // Total: 400ms delay on product page load
}

Solution: Message queue (async)

Step 1: Define message type in queue config

<!-- app/code/BroCode/ImageOptimizer/etc/queue_topology.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Framework:etc/queue_topology.xsd">
  
  <!-- Define the queue topic -->
  <exchange name="brocode_image_optimizer" type="topic" connection="amqp">
    <binding id="brocode_image_convert_binding" topic="image.convert" destination="brocode_image_convert_queue" />
  </exchange>
  
  <!-- Define the queue -->
  <queue name="brocode_image_convert_queue" connection="amqp" />
</config>

Step 2: Publisher (sends message on product save)

// app/code/BroCode/ImageOptimizer/Observer/ProductImageConvertObserver.php
<?php
declare(strict_types=1);

namespace BroCode\ImageOptimizer\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\MessageQueue\PublisherInterface;

class ProductImageConvertObserver implements ObserverInterface
{
  public function __construct(
    private PublisherInterface $publisher
  ) {}

  public function execute(Observer $observer): void
  {
    $product = $observer->getEvent()->getProduct();
    
    // Send async message (doesn't block page load)
    $this->publisher->publish('image.convert', json_encode([
      'product_id' => $product->getId(),
      'image' => $product->getData('image'),
      'formats' => ['webp', 'avif']  // Formats to generate
    ]));
  }
}

Step 3: Message handler (processes async)

// app/code/BroCode/ImageOptimizer/Model/Queue/ImageConvertHandler.php
<?php
declare(strict_types=1);

namespace BroCode\ImageOptimizer\Model\Queue;

use BroCode\ImageOptimizer\Service\ImageProcessor;
use Magento\Catalog\Api\ProductRepositoryInterface;

class ImageConvertHandler
{
  public function __construct(
    private ImageProcessor $imageProcessor,
    private ProductRepositoryInterface $productRepository
  ) {}

  public function execute(string $message): void
  {
    $data = json_decode($message, true);
    $productId = $data['product_id'];
    $image = $data['image'];
    $formats = $data['formats'];
    
    try {
      // Heavy work happens here (doesn't block UX)
      foreach ($formats as $format) {
        $this->imageProcessor->convert($image, $format);
      }
      
      // Log success
      error_log("Image optimization complete for product {$productId}");
    } catch (\Exception $e) {
      // Log error, retry logic handled by MQ
      error_log("Image optimization failed: " . $e->getMessage());
    }
  }
}

Step 4: Point the consumer at the handler

Handlers are not wired in di.xml. There is no handler-registry type to extend – the binding lives in etc/queue_consumer.xml, and the topic’s payload contract in etc/communication.xml:

<!-- app/code/BroCode/ImageOptimizer/etc/communication.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Communication/etc/communication.xsd">
    <topic name="brocode.image.convert"
           request="BroCode\ImageOptimizer\Api\Data\ImageConversionMessageInterface"/>
</config>
<!-- app/code/BroCode/ImageOptimizer/etc/queue_consumer.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework-message-queue:etc/consumer.xsd">
    <consumer name="BroCodeImageConversionConsumer"
              queue="brocode_image_conversion"
              connection="db"
              handler="BroCode\ImageOptimizer\Model\Queue\ImageConvertHandler::execute"/>
</config>

Note connection="db", which uses Magento’s MySQL queue and needs no extra infrastructure. Switching to RabbitMQ later is a matter of changing that attribute and adding queue_topology.xml – the handler code does not change. Constructor dependencies for the handler need no di.xml entry at all unless you are binding an interface to a concrete class.

Step 5: Register observer

<!-- app/code/BroCode/ImageOptimizer/etc/frontend/events.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
  
  <event name="catalog_product_save_after">
    <observer name="brocode_image_convert" instance="BroCode\ImageOptimizer\Observer\ProductImageConvertObserver" />
  </event>
</config>

Step 6: Run the queue consumer (background worker)

# In production, run this continuously:
php bin/magento queue:consumers:start brocode_image_convert --max-messages=100

# Or in DDEV:
ddev exec php bin/magento queue:consumers:start brocode_image_convert

Impact:

Before (blocking):

Product page load timeline:
├─ HTML render: 200ms
├─ Image convert JPEG→WebP: 200ms (BLOCKS!)
├─ Image convert JPEG→AVIF: 200ms (BLOCKS!)
└─ Total: 600ms (CLS issues from late images)

After (async queue):

Product page load timeline:
├─ HTML render: 200ms
├─ Queue message sent: <1ms (async)
└─ Total: 201ms (page loads immediately!)

Background worker (doesn't affect UX):
├─ Image convert JPEG→WebP: 200ms
├─ Image convert JPEG→AVIF: 200ms
└─ Completes ~1.5s later (images already displayed)

Result: Page load time reduced 66% (600ms → 201ms), CLS improved by avoiding late image loads.

The CLS prevention checklist

  • [ ] All <img> tags have width/height or aspect-ratio
  • [ ] Ad/notification containers have reserved space or use fixed positioning
  • [ ] Fonts use font-display: swap + system font fallback
  • [ ] Heavy image conversion runs async (message queue, not on request)
  • [ ] No dynamic content injection to document flow (use fixed/absolute positioning)
  • [ ] Lazy-loaded images have correct aspect ratio placeholders
  • [ ] Video embeds (YouTube, Vimeo) have reserved container
  • [ ] No synchronous external scripts loading during interaction
  • [ ] CLS <0.1 verified via Lighthouse (mobile)
  • [ ] Real-user CLS monitoring active (PerformanceObserver or Web Vitals API)

Result: Stable, shift-free page load experience (CLS ≤0.1)


tradeoff: reserved space costs you layout, and yielding costs you simplicity

Both fixes in this article are trades, and pretending otherwise is how they get reverted.

Reserving space to prevent shift means committing to dimensions before you know the content. A promotional banner slot that is always 90 px tall does not shift the page — and it leaves a 90 px gap on every page where there is no promotion. Designers will notice. The honest answer is usually to reserve space only where content is genuinely likely, and to render the empty state deliberately rather than collapsing it.

Breaking up long tasks makes code meaningfully harder to read. A straightforward loop becomes a chunked, yielding, scheduler.yield()-or-setTimeout affair, and the next developer has to understand why before they can safely change it. That complexity is worth it on the two or three interactions that carry revenue and worth nothing anywhere else. Yielding inside a rarely-used admin filter is pure cost.

There is also a ceiling worth stating plainly, because teams grind against it: on a storefront carrying heavy third-party tags, some INP is simply not yours. A chat widget or consent manager that occupies the main thread for 200 ms is an INP problem you can measure precisely and cannot fix in code. Escalating that with numbers attached is a legitimate outcome of this work — often the highest-value one.


verification: proving both actually moved

Lab first for the fast feedback loop, field second for the truth.

// INP — interact with the page normally, then read the worst interaction
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.interactionId) {
      console.log('interaction', Math.round(e.duration), 'ms', e.name, e.target);
    }
  }
}).observe({ type: 'event', durationThreshold: 40, buffered: true });

// CLS — with the culprit node attached, which is the part that matters
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (!e.hadRecentInput) {
      console.log('shift', e.value.toFixed(4), e.sources?.map(s => s.node));
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Run those on a throttled connection with 4x CPU slowdown, then do the things a customer does: open a category, filter it, open a product, change a swatch, add to cart, open the minicart. The interactions that matter are the ones with money attached, and they are exactly the ones no automated audit performs.

Then check the field. Search Console’s Core Web Vitals report and CrUX are the only sources that reflect real devices on real networks. A change that looks decisive in DevTools and does not move the 75th percentile in the field after four weeks did not work, however good the reasoning was.

Pass criteria: INP at or under 200 ms and CLS at or under 0.1, both at the 75th percentile of field data, on both mobile and desktop, holding across a full four-week window rather than on the day of the deploy.


Related reading

Sources & References

Disclaimer

Interaction timings depend heavily on device class; a mid-range Android phone will produce numbers several times worse than a development laptop, and the 75th percentile of your field data is closer to the phone. Test throttled, and treat any conclusion drawn only from desktop DevTools as provisional.

Related Topics

← Previous
Next →

Written in collaboration with AI (Claude, by Anthropic). Ideas, verification, and accountability are mine; research and drafting are AI-assisted. Full disclosure → · Found an error? Tell me.