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
| Task | Owner | Effort | Why This Owner |
|---|---|---|---|
| INP profiling (DevTools) | Frontend | 2–4 hours | Browser tools, interaction testing |
| Observer chain audit | Backend | 2–4 hours | Code review, Magento expertise |
| Move observers to async queue | Backend | 1–2 weeks | MessageQueue integration, testing |
| Typeahead debouncing | Frontend | 2–4 days | JavaScript optimization |
| Modal/drawer performance | Frontend | 1–2 weeks | Lazy rendering, code-splitting |
| Web Worker implementation | Frontend | 1–2 weeks | Parallel processing setup |
| Long task detection | Frontend + DevOps | 2 hours | Monitoring 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
| Task | Owner | Effort | Why This Owner |
|---|---|---|---|
| CLS profiling (DevTools) | Frontend | 1–2 hours | Browser tools, visual inspection |
| Image dimension requirements | Frontend + Content | 1–2 days | Template specs, content guidelines |
| Template updates (width/height) | Frontend | 2–4 days | Phtml/Vue updates, Magento templates |
| Reserved space CSS | Frontend | 1–2 days | CSS placeholders, skeleton screens |
| Async image conversion queue | Backend + DevOps | 1–2 weeks | MessageQueue setup, image processing |
| Ad container setup | Frontend + Marketing | 1–2 days | Fixed positioning, placeholder sizing |
| Font loading optimization | Frontend | 1–2 days | font-display property, fallbacks |
| Testing (visual regression) | Frontend + QA | 2–4 hours | Cross-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
- Magento 2 Core Web Vitals: From Fails to Passes — the pillar this deep dive sits under.
- Magento JavaScript Optimization: Audit and Defer — most long tasks arrive as somebody else’s script.
- Magento Font Optimization: Self-Hosted, Non-Blocking — the swap-induced shift, and how to size a fallback so it does not happen.
- Magento LCP Images: Measure First, Then Convert — missing
width/heightis the most common CLS cause of all. - Magento TTFB: Varnish, Redis and the Backend Cost — the floor everything else is measured against.
- Magento 2 and MCP: Run Your Store by Conversation — for pulling field data without living in Search Console.
Sources & References
- web.dev: Interaction to Next Paint — the metric definition, and why it replaced FID.
- web.dev: optimize INP — input delay, processing time and presentation delay as separate problems.
- web.dev: Cumulative Layout Shift — the session-window scoring model.
- web.dev: optimize CLS — reserved space, font swap shift and dynamic content.
- Chrome UX Report — the field data both metrics are actually judged on.
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.