Skip to content

Why the Add to cart button feels slow, and how to fix it by measuring INP

From the work/ 13 min read

The customer taps Add to cart and nothing moves. How to find the script that owns that delay with INP, fix it in a Shopify theme, and prove the fix worked.

  • Shopify
  • Performance
  • Core Web Vitals
  • JavaScript
In this article (7)
  1. What INP measures
  2. Why it happens on Shopify
  3. Diagnose it step by step
  4. The fix, in order
  5. What not to do
  6. Checklist for reviewing a store
  7. How to know it worked

A customer picks a size, taps Add to cart, and nothing happens. The button does not change, no spinner appears, no drawer opens. So they tap again. Sometimes the second tap adds a second unit, sometimes it opens the drawer twice, and sometimes the customer has already gone back to the search results. Whoever reports it says "the site is slow", and the first thing the team checks is the server.

The server is almost never the culprit here. A request to the cart takes time, but waiting for a network response does not freeze a page: the browser can paint, scroll and respond to input while a fetch is in flight. What freezes a button is the main thread, the single thread where the browser runs your JavaScript, handles input and produces frames. If that thread is busy when the tap arrives, or becomes busy because of the tap, the next frame cannot be drawn, and the customer sees a button that ignored them. That delay has a metric: Interaction to Next Paint, or INP.

This guide is about that one interaction. For the wider argument about why stores get slow in the first place, read store speed is usually an architecture problem. Here we stay on the product page, on one button, and on measuring it before and after changing anything.

What INP measures

INP observes clicks, taps and key presses for the whole time a page is open. Hovering, zooming and scrolling are not counted. For each interaction it measures the time from the input to the next frame the browser presents. The page's INP is the longest of those, ignoring one outlier for every 50 interactions, so on most product pages it is simply the worst interaction of the visit. INP replaced First Input Delay as a Core Web Vital in March 2024. FID only measured the delay before the first interaction's handlers ran, so a slow Add to cart button could pass it.

The official thresholds, measured at the 75th percentile of page loads in the field and split by mobile and desktop:

  • Good: 200 milliseconds or less.
  • Needs improvement: above 200 and up to 500 milliseconds.
  • Poor: above 500 milliseconds.

Every interaction breaks down into three phases, which web.dev's guide to optimizing INP defines like this:

  1. Input delay runs from the tap until the event callbacks start. It is long when the main thread is already busy with something else, such as a script that is still initializing.
  2. Processing duration is the time for all the event callbacks of that interaction to run to completion. All of them: the theme's handler and every handler an app attached to the same button or form.
  3. Presentation delay is the time the browser needs to present the next frame: recalculating styles, layout and paint after the handlers changed the page.

Scroll the figure sideways to see all of it

The split matters because each phase has a different fix. A long input delay is a problem with what was already running. A long processing duration is a problem with the handlers. A long presentation delay is a problem with how much of the page those handlers changed.

Why it happens on Shopify

The Add to cart button is the most crowded element on a product page. Everything that wants to know about purchase intent listens to it.

  • Apps that hook the click. Upsell pop-ups, "frequently bought together" blocks, free-shipping bars and cart drawers from apps often attach their own listener to the button or the product form. Some intercept the submit, run their own logic and then continue it. Each listener adds to processing duration, and the browser cannot paint until the last one returns.
  • Analytics and tags. A tag manager container or a marketing script hard-coded into theme.liquid that sends an add-to-cart event from inside the click handler, synchronously, with its own payload building. Shopify's own web pixels load in a sandbox (strict for app pixels, lax for custom pixels) instead of inside the theme's code, which is one reason to move tracking there.
  • Theme scripts. The theme's product form often re-renders the cart drawer, the cart count, the price and the free-shipping bar after the request returns. Large HTML swaps inflate presentation delay.
  • Blocks that initialize late. App blocks and app embeds load their own JavaScript and set themselves up on load. If a customer taps while a reviews widget or a slider is still booting, the tap waits behind it. That is input delay, and it is worst in the first seconds after the page appears, which is exactly when an impatient customer taps.

None of this appears in a server log. The cart request can come back in a fraction of a second and the interaction can still be poor.

Diagnose it step by step

1. Reproduce it in the lab with a throttled CPU

Your laptop is several times faster than the phone most of your customers use, so a tap that feels instant at your desk can be slow in a pocket. In Chrome DevTools, open the Performance panel, open Capture settings and set CPU to 4x slowdown. Chrome's own Performance panel tutorial uses 4x and suggests 20x for low-end mobile. Throttling is relative to your machine, and the panel can also calibrate presets for your device.

Start recording, select a variant, tap Add to cart, wait for the drawer, and stop recording. Then read the trace in this order, using the Performance panel reference:

  1. In the Interactions track, find the click. Interactions over 200 milliseconds carry a red triangle, and hovering one shows input delay, processing time and presentation delay separately.
  2. On the Main track below it, find the task that starts at the click. Long tasks are marked with a red triangle, and the part beyond 50 milliseconds is shaded red.
  3. Select that task and open Bottom-up, grouped by URL or domain. The table tells you which script owns the time. A file on an app's CDN, the tag manager or the theme's own product form script each point to a different owner and a different conversation.
  4. Turn on Dim 3rd parties to see at a glance how much of the task is code you did not write.

Record it a few times, and once more during load, tapping as soon as the button appears. That second recording is where input delay shows up.

2. Find out which interaction fails for real customers

The lab tells you what happened on your machine. To learn which element and which phase are slow for customers, use Google's web-vitals library with its attribution build, as described in Find slow interactions in the field:

import { onINP } from 'web-vitals/attribution';

onINP(({ value, rating, attribution }) => {
  const body = JSON.stringify({
    inp: value,                                  // ms
    rating,                                      // 'good' | 'needs-improvement' | 'poor'
    target: attribution.interactionTarget,       // CSS selector of the element touched
    inputDelay: attribution.inputDelay,
    processing: attribution.processingDuration,
    presentation: attribution.presentationDelay,
    loadState: attribution.loadState,            // was the page still loading?
    script: attribution.longestScript?.entry.sourceURL, // Chromium only
    page: location.pathname,
  });
  // Your own collector. sendBeacon still delivers when the tab is closing.
  navigator.sendBeacon('https://collector.example.com/inp', body);
});

interactionTarget is a CSS selector for the element the customer touched, so you can group reports and see whether Add to cart is really the worst interaction or whether it is the variant picker or the drawer's close button. The three durations tell you which phase to work on. loadState tells you whether the page was still loading, which points at input delay from scripts still booting. longestScript comes from the Long Animation Frames API, which only Chromium browsers ship, so it is missing for some visitors; when it is present, its sourceURL names the script. If the theme has no bundler, the library's README shows how to load it as a module from a CDN.

3. Read the field data, and why it differs from the lab

PageSpeed Insights shows two different things on one page. The top section is field data from the Chrome UX Report (CrUX) for the previous 28-day collection period, and INP appears only there. The section below is a Lighthouse lab run that loads the page on one simulated device and never taps your button, so it cannot measure INP for Add to cart at all.

Expect the two to disagree. According to the CrUX methodology, the report only includes Chrome users on desktop and Android who opted in to share usage statistics; Safari and Chrome on iOS do not contribute. A URL needs enough traffic to get page-level data, and otherwise PageSpeed Insights falls back to the whole origin, mixing your product page with the homepage. And the field number covers 28 days, so a fix shipped yesterday is mostly invisible today. For one specific button, your own data from step 2 is the more useful field signal.

The fix, in order

The principle comes straight from web.dev: show the customer something in the next frame, and move everything they cannot see out of the way.

a. Visual response first

The first thing the handler does is change the button: a label such as "Adding…", aria-disabled, perhaps a class. Nothing else. It is a tiny DOM change, and it is what the next frame should paint. If the button has inner markup, as in themes that wrap the label in a span next to a spinner, change the span rather than the whole button.

b. Yield the main thread

Then give the browser the chance to paint that change before any heavy work. scheduler.yield() returns a promise and schedules the rest of the function as a prioritized continuation, so it resumes ahead of other queued work of the same priority. MDN marks it as limited availability, and web.dev's long-tasks guide lists Chrome and Edge 129 and Firefox 142, with Safari not supported. So you feature-detect and fall back to setTimeout, which still yields, only without the priority.

c. Send analytics last

Tracking does not need to run before the customer sees a response. Send it after the cart updates, inside requestIdleCallback with a timeout so it still runs on a busy page. MDN marks requestIdleCallback as not Baseline, so it needs a fallback as well.

d. Applied to a Shopify theme

Here is the whole pattern on a product form that posts to the Ajax Cart API. /cart/add.js takes an items array of variant id and quantity, and Shopify asks for locale-aware URLs, which is what window.Shopify.routes.root gives you. A sold-out variant, or a quantity above the stock, comes back as a 422 with a readable description.

// Prefer scheduler.yield(); fall back to setTimeout where it is missing (Safari).
function yieldToMain() {
  if (globalThis.scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

// Run non-urgent work when the browser is idle, but never later than 2 s.
function whenIdle(fn) {
  if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 2000 });
  else setTimeout(fn, 0);
}

const form = document.querySelector('form[action$="/cart/add"]');
const button = form.querySelector('[type="submit"]');

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  // Ignore repeat taps while a request is in flight. Not a debounce:
  // the first tap is never delayed.
  if (button.getAttribute('aria-disabled') === 'true') return;

  // 1. Visual response first. This small change is what the next frame paints.
  const label = button.textContent;
  button.setAttribute('aria-disabled', 'true');
  button.textContent = 'Adding…'; // use the theme's translation string

  // 2. Let the browser present that frame before anything else runs.
  await yieldToMain();

  try {
    const data = new FormData(form);
    const res = await fetch(window.Shopify.routes.root + 'cart/add.js', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        items: [{ id: Number(data.get('id')), quantity: Number(data.get('quantity') || 1) }],
      }),
    });
    const json = await res.json();
    // 422 means sold out or not enough stock; the description is customer-readable.
    if (!res.ok) throw new Error(json.description || json.message);

    updateCartDrawer(json.items); // the theme's own update: replace only what changed

    // 3. Analytics last, off the path to the next frame.
    whenIdle(() => trackAddToCart(json.items));
  } catch (error) {
    showFormError(form, error.message);
  } finally {
    button.textContent = label;
    button.removeAttribute('aria-disabled');
  }
});

updateCartDrawer, trackAddToCart and showFormError stand for whatever your theme already has. Two notes on the rest. First, this code only controls your own handler. If an app listens to the same click and runs for hundreds of milliseconds, it still runs before the frame, and the fix for that is a setting in the app, a conversation with the vendor, or removing the app. The Bottom-up table from step 1 is what you bring to that conversation. Second, if the theme re-renders the drawer with the sections parameter, which the cart endpoints accept for up to five sections, keep the swap small. Replace the line items and the count, not the whole drawer, or the cost reappears as presentation delay on the next interaction.

Scroll the figure sideways to see all of it

What not to do

  • Debounce the button. A debounce waits before running the handler, so it adds latency to the first tap, which is the one that matters. Ignoring taps while a request is in flight, as the code above does, prevents double adds without delaying anything.
  • Remove analytics without telling anyone. Deleting the tag that sends add-to-cart events will improve INP and quietly break the funnel the marketing team uses to decide where money goes. Move it later or into a pixel, and tell the people who read the reports before their numbers change.
  • Optimize without measuring. Rewriting the product form because it "feels heavy" can fix a handler that takes 20 milliseconds while an app's listener takes ten times that. Measure first, then change the thing the trace names.

Checklist for reviewing a store

  1. Field INP for product pages on mobile, from PageSpeed Insights, written down with the date.
  2. Your own field data from the web-vitals attribution build, grouped by interactionTarget.
  3. A DevTools trace of Add to cart at 4x CPU slowdown, once after load and once during load.
  4. Every listener on the button and the product form, from the Event Listeners pane in the Elements panel, with the script that owns each one.
  5. The button changes visibly in the first frame after the tap.
  6. A yield between that change and any heavy work, with a setTimeout fallback.
  7. Analytics after the cart update, idle-scheduled, and the owners of the reports informed.
  8. The drawer update replaces only what changed.

How to know it worked

Measure after the change with exactly the method you used before. In the lab that means the same device, the same CPU throttling, the same product and variant, the same steps and the same number of recordings; take five and compare the median, phase by phase, not only the total. A change that moves processing duration but leaves input delay untouched tells you something different from one that moves both.

In the field, compare your own data for the same length of time before and after the release, split by device type and by interactionTarget. Read PageSpeed Insights again only once a full 28-day window has passed after the release, because until then the window still contains sessions from before the fix. And check whether the traffic mix changed in the meantime: a shift in paid social budget changes which phones arrive, and that moves INP on its own.

An illustrative example, not a measurement from any store: suppose five throttled traces put the click task at roughly 400 milliseconds, most of it inside an upsell app's listener, and after configuring the upsell to run when the drawer opens the same five traces show under 100. That is a lab result you can explain to anyone. Whether the field 75th percentile drops under 200 milliseconds is a question for the next 28 days, and your own data will answer it sooner.

If you would rather hand this over, it is part of what we do in performance optimization. The deliverable is the trace, the list of script owners and a before-and-after measured the same way.

Written by

Luis Morales

Luis Morales

Frontend and Shopify developer, EcomLabs

@luismoralesdev on Instagram

Published October 21, 2026 · updated October 23, 2026

← Blog