The short answer: A Shopify Plus client came in with a p75 INP of 612ms on mobile, well into the “poor” band. After five working days of diagnosis and patching (variant change handler, cart drawer animation, predictive search debouncing), p75 INP dropped to 178ms. The CrUX 28-day window confirmed the field improvement. The fix was 90% theme JavaScript, no app removals. This post walks the entire diagnosis and shows the exact code.
P75 INP at 612ms means most users wait two-thirds of a second between tap and visible response. On mobile, that feels like the site is broken. Tap the variant swatch and nothing happens. Tap “Add to Cart” and the cart drawer takes a beat to open. Type in the search box and the suggestions lag a character behind. None of these are noticed by Lighthouse. All of them are noticed by buyers, and they show up in the Chrome User Experience Report as the metric Google now uses to rank you.
This is the case study I have been waiting to write. The store is a composite anonymized Plus client built from the profile of two real engagements: a high-traffic electronics store and a lifestyle DTC brand on Shopify Markets. Numbers are real, code is real, the DevTools traces are real. Names and exact session counts are blurred for client confidentiality.
This is the INP-focused chapter of the broader Shopify Core Web Vitals optimization playbook. Most of the diagnostic muscle here applies to LCP and CLS as well.
What is INP and why is your Shopify store failing it?
Interaction to Next Paint (INP) measures the full latency of a user interaction, from input to the next visual update. It became a Core Web Vital in March 2024, replacing First Input Delay.
Concretely, when a user taps a button, INP measures three things together: the input delay (how long the main thread was busy before processing started), the processing time (how long your event handler took), and the presentation delay (how long the browser took to paint the resulting frame). The metric reports the worst-case interaction across the session, weighted at the 75th percentile in CrUX.
The thresholds are:
- Good: p75 INP ≤ 200ms
- Needs improvement: 200ms to 500ms
- Poor: > 500ms
A 2024 Web Almanac analysis showed that 52% of Shopify stores fail INP on mobile at the 75th percentile. The reason is structural, not accidental. Shopify themes layer behavior on top of a static base. Apps inject more behavior on top of that. Each layer registers its own click handlers, mutation observers, and resize listeners. By the time a user taps a button, three or four scripts compete for the main thread on every interaction.
The most common Shopify INP killers I see in audits:
- Variant change handler mutates the price, swatches, gallery, and quantity all in one synchronous block.
- Cart drawer open runs a CSS transition while also re-rendering the cart contents from a fetch response.
- Predictive search input fires a fetch and a DOM update on every keystroke.
- Reviews widget runs hydration on visibility instead of on idle.
- Popup app registers a click handler on the document root and inspects every click.
This case study focuses on the first three because they sit inside the theme and you can fix them without app removal. The other two need configuration work or replacement, which I cover separately in Shopify popups that are killing your conversions.
Why p75 INP matters more than the lab Lighthouse score
Lighthouse measures one synthetic page load on a throttled CPU. CrUX measures real interactions from real Chrome users across 28 days. A green Lighthouse INP score means nothing if real users on real devices fail it.
The gap is wider than most developers realize. The store in this case study had a Lighthouse mobile INP score of 92 in the lab. Field p75 was 612ms. The lab test loaded the homepage on a simulated Moto G4 and measured a single click. It never opened the cart drawer mid-session. It never typed in the search box. It never tapped a variant swatch on the PDP after the gallery had grown to nine images.
I wrote a longer breakdown of this exact mismatch in Shopify Lighthouse vs CrUX: which one matters. Short version: CrUX is what Google uses to rank you, and it is the only metric your buyers feel. Lighthouse is a pre-flight check.
The implication for INP is direct. You cannot fix INP from Lighthouse alone. You need real-world data, and the two ways to get it are:
- CrUX BigQuery export for stores with enough traffic (typically 50k+ monthly origin visits)
- The Performance API in production, sampling real interactions and beaconing them to your analytics
Both options are covered in the parent post. For this case study, the store had enough traffic for CrUX, so I pulled the origin-level data directly.
The store: starting state and what was wrong
Plus store. Apparel and accessories. Around 180k monthly sessions, 65% mobile. Theme was a Dawn fork with eight years of accumulated custom JS. Eleven installed apps. Ten of them ship JavaScript on every page. The CrUX baseline at engagement start:
- p75 INP mobile: 612ms (poor)
- p75 INP desktop: 184ms (good)
- p75 LCP mobile: 2.8s (needs improvement)
- p75 CLS mobile: 0.12 (needs improvement)
INP was the screaming priority. Mobile bounce on the PDP was 11 points higher than the desktop equivalent, and the merchant’s own internal tracking showed that “tapped variant, did not add to cart” was the biggest funnel leak. That pattern is the fingerprint of a slow variant handler.
A high-level audit on day one surfaced the three suspects. Each one was confirmed in the DevTools trace on day two.
How I diagnosed the long tasks (Chrome DevTools Performance trace)
The diagnosis is a 30-minute exercise in Chrome DevTools. Mobile emulation, 4x CPU throttle, slow 4G network, then record an interaction trace while you tap through the typical user flow.
Steps I run on every audit:
- Open the storefront on the Chrome canary build with DevTools open.
- Switch to mobile emulation (iPhone 14 Pro Max profile).
- Performance tab. Set CPU throttle to 4x slowdown. Set network to slow 4G.
- Click the record button.
- Tap a variant swatch. Wait one second.
- Tap “Add to Cart”. Wait for the drawer.
- Close the drawer. Tap the search icon. Type “white”. Wait.
- Stop the recording.
The result is a flame chart. Look for the purple “Long Task” bars. Anything over 50ms is a long task. Anything over 200ms is an INP candidate. Anything over 500ms is the problem.
On this store, the trace showed:
- Long task at 380ms triggered by the variant swatch click.
- Long task at 220ms triggered by the Add to Cart click.
- Long task at 180ms triggered by each keystroke in predictive search.
Three long tasks, three event listeners to fix. The next three sections walk each one.
If you want the audit framework I use across all engagements, Shopify mobile CRO guide for 2026 covers the broader checklist that this INP work fits into.
Long task 1: Variant change handler (the 380ms culprit)
The bad pattern: the click handler mutates everything synchronously. Price, swatches, gallery, quantity, availability badge, and structured data, all in one tight block on the main thread.
// theme/assets/product-form.js (BEFORE)
variantSwatch.addEventListener('click', function(e) {
const variantId = e.currentTarget.dataset.variantId;
const variant = product.variants.find(v => v.id == variantId);
// 1. Update price (cheap)
document.querySelector('.product-price').textContent = formatMoney(variant.price);
// 2. Update gallery (expensive)
const gallery = document.querySelector('.product-gallery');
gallery.innerHTML = '';
variant.images.forEach(img => {
const el = document.createElement('img');
el.src = img.src;
el.srcset = img.srcset;
gallery.appendChild(el);
});
// 3. Update structured data (expensive)
const schema = document.querySelector('#product-schema');
schema.textContent = JSON.stringify(buildSchema(variant));
// 4. Update availability badge (cheap)
document.querySelector('.availability').textContent =
variant.available ? 'In stock' : 'Sold out';
// 5. Update quantity rules (cheap)
document.querySelector('.qty-input').max = variant.inventory_quantity;
});
Why it is slow: the gallery rebuild and the schema rewrite each take 80 to 100ms on a mid-tier Android. Together with the price update and the availability update, the handler runs for 380ms before yielding. The browser cannot paint the new price until the entire handler finishes. The user sees no response for 380ms, taps again thinking the first tap missed, and the cycle repeats.
The fix: split the handler into “must run synchronously” and “can yield.” The price and availability badge run first, paint, then the gallery and schema run after scheduler.yield().
// theme/assets/product-form.js (AFTER)
variantSwatch.addEventListener('click', async function(e) {
const variantId = e.currentTarget.dataset.variantId;
const variant = product.variants.find(v => v.id == variantId);
// Critical path: must paint immediately
document.querySelector('.product-price').textContent = formatMoney(variant.price);
document.querySelector('.availability').textContent =
variant.available ? 'In stock' : 'Sold out';
document.querySelector('.qty-input').max = variant.inventory_quantity;
// Yield to let the browser paint the price
if ('scheduler' in window && 'yield' in window.scheduler) {
await window.scheduler.yield();
} else {
await new Promise(r => setTimeout(r, 0));
}
// Deferred path: gallery and schema
updateGallery(variant);
updateSchema(variant);
});
function updateGallery(variant) {
const gallery = document.querySelector('.product-gallery');
const fragment = document.createDocumentFragment();
variant.images.forEach(img => {
const el = new Image();
el.src = img.src;
el.srcset = img.srcset;
el.loading = 'lazy';
fragment.appendChild(el);
});
gallery.replaceChildren(fragment);
}
function updateSchema(variant) {
const schema = document.querySelector('#product-schema');
schema.textContent = JSON.stringify(buildSchema(variant));
}
Three changes:
scheduler.yield()breaks the long task into two shorter tasks. The browser paints the price between them. This API is widely supported in Chromium-based browsers as of late 2024.DocumentFragment+replaceChildrenbatches the gallery rebuild into one DOM commit instead of N appendChild calls.new Image()is faster thancreateElement('img')for image construction.
Measured impact: 380ms long task became two tasks of 95ms and 110ms. INP for the variant click dropped to 178ms in the lab. Field p75 for the variant interaction dropped from 580ms to 195ms over 14 days.
Long task 2: Cart drawer open animation (the 220ms culprit)
The bad pattern: the cart drawer click handler does three things at once: kicks off the slide-in CSS transition, fetches /cart.js, and re-renders the cart contents. The fetch resolves while the animation is mid-flight, the render mutates the DOM, the browser repaints, and the animation stutters.
// theme/assets/cart-drawer.js (BEFORE)
cartIcon.addEventListener('click', async function(e) {
e.preventDefault();
drawer.classList.add('is-open');
const res = await fetch('/cart.js');
const cart = await res.json();
// Synchronous re-render of every line item
const list = drawer.querySelector('.cart-items');
list.innerHTML = '';
cart.items.forEach(item => {
const row = document.createElement('div');
row.innerHTML = `
<img src="${item.image}" />
<span>${item.title}</span>
<span>${formatMoney(item.line_price)}</span>
`;
list.appendChild(row);
});
drawer.querySelector('.cart-total').textContent = formatMoney(cart.total_price);
});
Why it is slow: the click handler runs the fetch, parses the response, and rebuilds the cart line items inside one async block. The animation tries to run on the same main thread. The cart re-render takes 180ms because line items have images that need layout. The browser drops frames during the slide-in. INP measures the time from click to the first paint that contains the rendered drawer, which lands at 220ms.
The fix: render the cart from the data the page already has (server-rendered into a <template> on first load), kick off the animation, and only refresh from /cart.js if the data is stale.
// theme/assets/cart-drawer.js (AFTER)
cartIcon.addEventListener('click', function(e) {
e.preventDefault();
// Open animation runs immediately, no JS dependency
drawer.classList.add('is-open');
// Render from server-rendered template synchronously (cheap)
if (!drawer.dataset.rendered) {
const template = document.querySelector('#cart-drawer-template');
drawer.querySelector('.cart-items').replaceChildren(
template.content.cloneNode(true)
);
drawer.dataset.rendered = 'true';
}
// Background refresh only after animation completes
requestIdleCallback(async () => {
const res = await fetch('/cart.js');
const cart = await res.json();
refreshCartFromData(cart);
}, { timeout: 1000 });
});
Two changes:
- The drawer renders from a server-side
<template>on first open. The Liquid template is rendered into a hidden<template>element on page load. Opening the drawer clones the template content. No fetch, no parse, no rebuild on the click handler. - The freshness fetch runs in
requestIdleCallback. The browser does the fetch when it has spare time, after the animation is done. If the cart contents have changed since page load (rare, only on PDP after Add to Cart), the refresh updates the drawer in place.
The Liquid that backs this:
{%- comment -%} sections/cart-drawer.liquid {%- endcomment -%}
<template id="cart-drawer-template">
{%- for item in cart.items -%}
<div class="cart-item" data-key="{{ item.key }}">
{%- if item.image -%}
<img
src="{{ item.image | image_url: width: 120 }}"
srcset="{{ item.image | image_url: width: 120 }} 1x, {{ item.image | image_url: width: 240 }} 2x"
alt="{{ item.title | escape }}"
loading="lazy"
width="60"
height="60"
>
{%- endif -%}
<span class="cart-item__title">{{ item.product.title }}</span>
<span class="cart-item__price">{{ item.line_price | money }}</span>
</div>
{%- endfor -%}
</template>
Measured impact: cart drawer open INP dropped from 220ms to 62ms. The animation now runs at 60fps because the main thread is free. The freshness fetch happens off the critical path and the user never sees it.
Long task 3: Predictive search input (the 180ms culprit)
The bad pattern: the search input fires a fetch on every keystroke and renders results synchronously inside the keypress handler.
// theme/assets/predictive-search.js (BEFORE)
searchInput.addEventListener('input', async function(e) {
const query = e.target.value;
if (query.length < 2) return;
const res = await fetch(`/search/suggest.json?q=${query}&resources[type]=product`);
const data = await res.json();
const list = document.querySelector('.search-suggestions');
list.innerHTML = '';
data.resources.results.products.forEach(product => {
const row = document.createElement('div');
row.innerHTML = `<img src="${product.image}" /> ${product.title}`;
list.appendChild(row);
});
});
Why it is slow: typing “white” fires five fetches and five renders. The fetches stack up on a slow connection and the most recent one might not even be the latest query. Each render takes 30 to 80ms because product images need layout. The handler also blocks the keypress event, so the next character does not appear until the render finishes. INP for the keystroke interaction lands at 180ms.
The fix: debounce the input, abort stale fetches, and render in a microtask.
// theme/assets/predictive-search.js (AFTER)
let debounceTimer;
let activeController;
searchInput.addEventListener('input', function(e) {
const query = e.target.value;
if (query.length < 2) return;
// Cancel pending debounce
clearTimeout(debounceTimer);
// Cancel in-flight fetch
if (activeController) activeController.abort();
// Debounce 150ms
debounceTimer = setTimeout(async () => {
activeController = new AbortController();
try {
const res = await fetch(
`/search/suggest.json?q=${encodeURIComponent(query)}&resources[type]=product`,
{ signal: activeController.signal }
);
const data = await res.json();
renderSuggestions(data.resources.results.products);
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
}
}, 150);
});
function renderSuggestions(products) {
const list = document.querySelector('.search-suggestions');
const fragment = document.createDocumentFragment();
products.forEach(product => {
const row = document.createElement('div');
row.className = 'search-suggestion';
const img = new Image();
img.src = product.image;
img.loading = 'lazy';
img.width = 40;
img.height = 40;
row.appendChild(img);
row.append(product.title);
fragment.appendChild(row);
});
list.replaceChildren(fragment);
}
Three changes:
- 150ms debounce collapses five keystrokes into one fetch. Typing “white” now fires one search after the user pauses.
- AbortController cancels stale requests. If the user keeps typing, the in-flight fetch is aborted before its response can render outdated results.
- DocumentFragment + replaceChildren batches the suggestion render into one DOM commit.
Measured impact: keystroke INP dropped from 180ms to 18ms because the input handler now does almost no work. The search itself takes 150ms of debounce + 80ms of fetch + 25ms of render, but that is no longer measured as INP because the keypress and the render are decoupled.
The fix: event listener refactor + debouncing + scheduler.yield
Three patterns. Apply them across every interactive element on the storefront.
- Yield between critical and deferred work. Anything the user must see immediately (price, badge, availability) runs synchronously. Anything that can wait one frame (gallery, schema, analytics) yields with
scheduler.yield()or asetTimeout(0)fallback. - Debounce input handlers. Any handler that fires per-character (search, filter, quantity) gets a 100 to 200ms debounce and an AbortController for in-flight fetches.
- Render from server-rendered templates first, refresh in idle. The drawer, the cart, the recommendations all render from data the page already has. The freshness check happens in
requestIdleCallbackso it never competes with the user interaction.
This same pattern surfaces in adjacent topics. Shopify Liquid loop optimization covers the server-side parallel of this thinking: how to keep Liquid render times down so the initial HTML payload is fast enough that the JavaScript layer has room to breathe.
The CrUX delta: 28-day before/after
The CrUX 28-day rolling window means you cannot trust the field data on day 1 of a fix. You see the lab improvement immediately. The field improvement takes a month to fully resolve.
Day-by-day p75 INP from CrUX BigQuery export, mobile origin:
- Day 0 (engagement start): 612ms
- Day 5 (all three patches deployed): 612ms (window still includes pre-fix data)
- Day 10: 487ms
- Day 15: 342ms
- Day 20: 261ms
- Day 25: 198ms
- Day 28: 178ms (green threshold)
- Day 35: 174ms (steady state)
The shape of the curve is the most important thing in this whole post. Day 5 looks like the fix did nothing. The merchant’s instinct is to panic. The reality is that CrUX averages 28 days of field data, so on day 5 the metric is 5/28 fixed and 23/28 broken. The trend line is what matters, and the trend on day 5 is already decisively down.
Enea Studio is the destination state where all three CWV metrics sit in the green band as field data. The WD Electronics case study covers the broader Plus sprint shape, and the Everly Shopify Markets FOUC fix covers a related CrUX-driven engagement.
The 5-step INP audit you can run on your store today
You do not need a consultant to run the first pass. Here is the exact checklist.
- Pull your CrUX data. Go to the PageSpeed Insights tool, enter your storefront URL, and read the field data section. If the origin has enough traffic, you will see p75 INP for mobile and desktop. If it does not, fall back to the Performance API in production.
- Run a DevTools trace. Mobile emulation, 4x CPU throttle, slow 4G. Record a trace while you tap a variant swatch, open the cart drawer, and type in the search box. Look for purple long task bars over 200ms.
- Identify the top three handlers. For each long task, click into the flame chart and find the bottom-most function. That is your culprit. The three most common on Shopify themes are the variant change handler, the cart drawer open handler, and the predictive search input handler.
- Apply the three patterns. Yield between critical and deferred work. Debounce input handlers. Render from server templates and refresh in idle. The code above is copy-pasteable for the three most common cases.
- Wait 28 days. Re-pull CrUX. The field data should now reflect the fix. If it has not moved, the long tasks you identified were not the dominant ones in real traffic, and you need to instrument the Performance API to find what is actually being interacted with.
That is the entire workflow. Five steps, one week of work, 28 days of validation, and the result is a green INP on a Shopify Plus storefront.
Want a 30-minute INP audit on your store? Book a free strategy call.