Why Interactions Are Often Too Slow in Magento Stores
INP breaks down into three measurable phases that most performance guides only cover on the surface. This deep dive examines input delay, processing time, and presentation delay individually, explains the role of third party scripts and Alpine.js reactivity in Hyva stores, and walks through the Chrome DevTools Performance panel to show exactly how long tasks get found and fixed.
Table of Contents
- 1. INP recap: why it deserves its own deep dive
- 2. Phase 1: understanding and measuring input delay
- 3. Phase 2: processing time and its causes
- 4. Phase 3: presentation delay and the rendering pipeline
- 5. Third-party scripts and main thread contention
- 6. Alpine.js reactivity in Hyva stores in detail
- 7. Long task profiling with the Chrome DevTools Performance panel
- 8. Practical fixes: debouncing, virtualization, yielding
- 9. INP antipatterns compared side by side
- 10. Summary
- 11. FAQ
1. INP recap: why it deserves its own deep dive
The Core Web Vitals article introduced INP as one of three metrics: under 200 milliseconds is good, caused by long JavaScript tasks. For day-to-day optimization work that is not enough, because "avoid long tasks" is a goal, not a debugging step. INP optimization fails in practice almost every time because teams target the wrong phase, since no one measured where the time actually goes. This deep dive supplies the missing measurement methodology.
INP breaks down into three measurable phases: input delay, processing time, and presentation delay. Google does not calculate the final value as an average, but as approximately the worst interaction of a session, usually the 98th percentile of all events grouped by interactionId. A single sluggish click on "Add to Cart" can therefore dominate a page's entire INP score, even if most other clicks run smoothly.
2. Phase 1: understanding and measuring input delay
Input delay starts the moment the operating system reports a click, tap, or keystroke to the browser, and ends when the responsible event handler begins to run. In between lies pure waiting time: the main thread has to be free before the input can be processed at all. If another task is already running at that moment, say a third-party script or a style recalculation, the input waits in the task queue until that task finishes.
A good input delay is a few milliseconds. Once a single task runs longer than 50 milliseconds, it counts as a long task and blocks any input arriving during that window for the remainder of its runtime. Typical culprits are synchronously loading tag manager containers, large JSON.parse calls for product data, or an initial Alpine init() pass over a very large component. Input delay is therefore rarely a problem with the handler itself, it is a symptom of main thread overload caused by other scripts.
// Measure INP phases (input delay, processing time, presentation delay) via the Event Timing API
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.interactionId) {
const inputDelay = entry.processingStart - entry.startTime;
const processingTime = entry.processingEnd - entry.processingStart;
const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;
console.table({
interactionType: entry.name,
inputDelay: Math.round(inputDelay),
processingTime: Math.round(processingTime),
presentationDelay: Math.round(presentationDelay),
totalDuration: Math.round(entry.duration)
});
// Flag interactions that exceed the 200ms "good" INP threshold
if (entry.duration > 200) {
console.warn(`Slow interaction detected: ${entry.name} took ${Math.round(entry.duration)}ms`);
}
}
}
});
// durationThreshold filters out sub-16ms interactions that are not actionable
observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
3. Phase 2: processing time and its causes
Processing time is the span from the start of the event handler to its complete finish, including all synchronously chained follow-up work: nested function calls, synchronously resolved promises, and every requestAnimationFrame callback that still runs before the next paint. In most Magento and Hyva stores, this phase is the largest contributor to a poor INP score, because this is where the actual application logic lives: cart updates, price calculations, filter logic, and DOM manipulation.
Processing time gets expensive mainly through three patterns: unnecessary recalculation of entire datasets on every keystroke instead of only when needed, alternating synchronous reads and writes of layout properties, and deeply nested reactive effects that cascade into re-evaluation from a single state change. The Chrome DevTools Performance panel shows processing time as a continuous yellow JavaScript block right after the event in the flame chart.
<!-- Alpine.js component with a debounced watcher to avoid re-filtering on every keystroke -->
<div x-data="{
query: '',
results: [],
init() {
// Only re-run the expensive filter after typing pauses for 250ms
this.$watch('query', Alpine.debounce(function (value) {
this.results = this.filterCatalog(value);
}, 250));
},
filterCatalog(value) {
// Runs against a pre-indexed array, not the full DOM
return window.productIndex.filter(p => p.name.toLowerCase().includes(value.toLowerCase()));
}
}">
<input type="text" x-model="query" placeholder="Search products..." class="border rounded-lg px-3 py-2 w-full">
<ul>
<template x-for="item in results" :key="item.sku">
<li x-text="item.name"></li>
</template>
</ul>
</div>
4. Phase 3: presentation delay and the rendering pipeline
Presentation delay starts once the event handler finishes and ends when the browser actually presents the updated frame on screen. In between lie style recalculation, layout, paint, and compositing, plus any further rendering work triggered by the interaction, such as an image decode or a CSS transition. This phase is the one most commonly overlooked during debugging, because developers primarily profile the JavaScript handler and ignore the rendering pipeline that follows it.
Large DOM trees, complex CSS selectors, expensive properties like box-shadow or filter on many elements, and undecoded images noticeably extend presentation delay. In the DevTools Performance panel it shows up as a purple "Rendering" section and a green "Paint" section right after the yellow processing block. For interactions that toggle many elements visible or hidden at once, such as a mega menu or a filter overlay, this third phase frequently dominates instead of the actual JavaScript execution.
5. Third-party scripts and main thread contention
Every third-party script, tag manager container, live chat widget, A/B testing tool, or tracking pixel registers its own event listeners, often directly on document in the bubble or capture phase. A single click therefore triggers not just your own Alpine handler, but a chain of foreign handlers, each claiming its own processing time. With three or four common third-party tools on a page, it is not unusual for the sum of their main thread time to clearly exceed the actual store logic.
Particularly critical are tag managers that synchronously load further containers on load and block several hundred milliseconds of main thread time in the process, often before the user has interacted at all, but with a direct impact on the input delay of the first real interaction. The most effective countermeasure is to load third-party scripts not during initial page build, but only after the first user interaction or after an idle timeout, combined with server-side tagging where possible to move main thread work off the browser entirely.
// Load third-party scripts only after the first genuine user interaction
// instead of blocking the main thread during initial page load
const thirdPartyScripts = [
{ src: '/js/tag-manager.js', name: 'Tag Manager' },
{ src: '/js/chat-widget.js', name: 'Chat Widget' },
{ src: '/js/ab-testing.js', name: 'A/B Testing' }
];
let loaded = false;
function loadThirdPartyScripts() {
if (loaded) return;
loaded = true;
thirdPartyScripts.forEach(({ src, name }) => {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.dataset.vendor = name;
document.body.appendChild(script);
});
}
// Trigger on first interaction, or after 5s idle as a fallback so bots still see the scripts
['pointerdown', 'keydown', 'touchstart'].forEach((eventType) => {
window.addEventListener(eventType, loadThirdPartyScripts, { once: true, passive: true });
});
setTimeout(loadThirdPartyScripts, 5000);
6. Alpine.js reactivity in Hyva stores in detail
Alpine.js is built on the same fine-grained reactivity system as Vue 3 (@vue/reactivity) and uses JavaScript proxies to re-run only the effects that actually depend on a changed property. Unlike React or Vue with virtual DOM diffing, there is no need to compare entire component trees, which structurally makes Alpine faster than classic framework approaches. That advantage flips, however, with large x-for loops: every rendered element gets its own reactive effects, and with several hundred facet or product rows, the number of effects grows proportionally.
If a shared state variable changes in that situation, say a search term, all dependent effects re-evaluate synchronously, directly inside the triggering event handler, which makes processing time balloon. The same applies to expensive computed expressions placed directly inside x-data objects or in x-text bindings that get recalculated on every effect run instead of cached. $watch callbacks without debouncing on frequently changing values, such as x-model bound search inputs, make the problem worse still.
<!-- Windowed rendering: only mount Alpine effects for rows inside the visible viewport -->
<div
x-data="{
allRows: window.categoryProducts,
rowHeight: 96,
visibleStart: 0,
visibleCount: 12,
get visibleRows() {
return this.allRows.slice(this.visibleStart, this.visibleStart + this.visibleCount);
},
onScroll(event) {
const scrollTop = event.target.scrollTop;
this.visibleStart = Math.max(0, Math.floor(scrollTop / this.rowHeight) - 2);
}
}"
x-on:scroll.passive="onScroll"
class="overflow-y-auto h-[600px] relative"
>
<!-- Spacer keeps scrollbar height correct without rendering every row -->
<div :style="`height: ${allRows.length * rowHeight}px; position: relative;`">
<template x-for="row in visibleRows" :key="row.sku">
<div
:style="`position: absolute; top: ${allRows.indexOf(row) * rowHeight}px; height: ${rowHeight}px;`"
class="w-full border-b border-slate-100 flex items-center px-4"
x-text="row.name"
></div>
</template>
</div>
</div>
7. Long task profiling with the Chrome DevTools Performance panel
In the Chrome DevTools Performance panel, the analysis starts with the "Screenshots" option enabled, a click on "Record", performing the interaction under investigation, for example a click on "Add to Cart", and then stopping the recording. Since Chrome 118, the "Interactions" track shows the INP breakdown directly, color coded: input delay, processing time, and presentation delay as separate segments at a glance, with no manual calculation required.
In the main thread flame chart below, long tasks over 50 milliseconds are marked with a red triangle in the upper right corner. Clicking such a block opens "Bottom-Up" and "Call Tree" further down, where the function with the highest "Self Time" reveals the actual culprit rather than just the calling wrapper. Custom performance.mark() and performance.measure() calls in the code additionally appear in the "Timings" track, letting you name and locate specific function blocks on demand.
8. Practical fixes: debouncing, virtualization, yielding
Debouncing helps against overly frequent recalculations, in Alpine directly via the x-on:input.debounce.300ms modifier or programmatically via Alpine.debounce() inside a $watch. Long lists, such as facets with hundreds of values or large product grids, should be virtualized: only the rows visible in the viewport get mounted, the rest exists only as a reserved placeholder with the correct height.
For unavoidably long computations, yielding is the most important lever: split the work into chunks and hand control back to the main thread between chunks via scheduler.yield(), or setTimeout(fn, 0) as a fallback, so waiting inputs can be processed. Heavy Alpine components, such as a product configurator, can be loaded via dynamic import() only when actually opened instead of during initial page build. Layout thrashing is prevented by first collecting all getBoundingClientRect() calls in a loop and only then batching every DOM write.
// Yield to the main thread between chunks of work to keep INP low
async function processFacetValues(items) {
const chunkSize = 40;
let index = 0;
while (index < items.length) {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
applyFacetValue(items[index]);
}
// Prefer the native scheduler API where available (Chrome 129+)
if ('scheduler' in window && 'yield' in window.scheduler) {
await window.scheduler.yield();
} else if ('isInputPending' in navigator) {
// Only yield if a pending user input is waiting to be processed
if (navigator.isInputPending({ includeContinuous: true })) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
} else {
// Fallback for older browsers: always yield between chunks
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}
9. INP antipatterns compared side by side
Each of the following five patterns hits a different INP phase and needs its own fix. The table below maps typical antipatterns from Magento and Hyva stores to the phase they affect.
| Pattern | Which INP Phase It Hurts | Bad Approach | Fix |
|---|---|---|---|
| Synchronous tag manager in head | Input Delay | Blocks the main thread before the first interaction | Load async/defer, delay init until first interaction |
| Large x-for loop without virtualization | Processing Time | Renders thousands of DOM nodes with their own Alpine effects | Windowed rendering, mount only visible rows |
| Expensive computed expression in x-data | Processing Time | Filters/sorts the entire catalog on every keystroke | Debounce plus memoize the computed value |
| Layout thrashing via getBoundingClientRect in a loop | Presentation Delay | Interleaved reads/writes force a reflow per iteration | Batch all reads first, then batch all writes |
| Unthrottled scroll/input handler | Input Delay / Processing Time | Fires dozens of times per second, backs up the task queue | Throttle/debounce with requestAnimationFrame |
In practice, several of these patterns overlap at once: a synchronous tag manager extends the input delay of the first interaction, while an unvirtualized product list drives up processing time on that same click. Measuring both causes individually via the DevTools breakdown, instead of vaguely "optimizing JavaScript", fixes INP problems permanently instead of just on the surface.
Mironsoft
INP profiling, Alpine.js performance, and main thread optimization for Magento stores
Ready to fix INP problems systematically?
We profile your Magento store's INP phases in the Chrome DevTools Performance panel, identify main thread blockers, and optimize Alpine.js components as well as third-party scripts for measurably better interaction times.
INP profiling audit
Phase breakdown, long task analysis, and prioritization by impact
Alpine.js performance refactoring
Virtualization, debouncing, and code splitting for heavy components
Third-party script governance
Load timing, server-side tagging, and a main thread budget per script
10. Summary
INP breaks down into three individually measurable phases, input delay, processing time, and presentation delay, and each has its own causes and its own fixes. Input delay comes from main thread overload, usually caused by third-party scripts. Processing time is dominated by inefficient application logic and uncontrolled Alpine reactivity across large lists. Presentation delay gets extended by expensive rendering work and layout thrashing, often independent of the actual JavaScript code.
The Chrome DevTools Performance panel with its Interactions track makes this breakdown visible instead of leaving it to guesswork. Debouncing, virtualization, yielding via scheduler.yield(), and code splitting for heavy Alpine components are the four most effective levers for keeping INP in Magento and Hyva stores permanently under 200 milliseconds, even for complex interactions like filtering or product configuration.
INP in Detail - The Essentials at a Glance
Input Delay
Waiting time before the handler starts. Usually caused by main thread blocks from other scripts.
Processing Time
Execution time of the handler including Alpine effects. Biggest lever for filter logic and large lists.
Presentation Delay
Time until the next frame. Extended by layout thrashing and expensive CSS properties.
Profiling & Fixes
DevTools Interactions track for diagnosis, scheduler.yield() and virtualization for the fix.