← Insights

Shopify interaction to next paint: what a tap costs on the product page, where the milliseconds go, and the 200 ms line

INP is the slowest tap of the visit, timed to the next frame. What the variant picker and add to cart do in Dawn and Horizon, what app embeds add, how to read it.

12 min read

Contents — 5 sections

The score is the slowest tap of the visit, timed to the frame after it

Interaction to next paint is the responsiveness metric among the three Core Web Vitals, and Google's definition on web.dev is narrower than the name suggests. An interaction is a click, a tap or a key press; hovering, scrolling and zooming are not observed. Its latency runs from the moment the finger lands to the moment the browser presents the next frame, and it has three parts: the input delay, which is the wait before the first event handler starts because the main thread is busy with something else; the processing duration, which is every handler that fires for the gesture, pointerdown, pointerup and click together; and the presentation delay, which is the style, layout and paint work needed to put the result on screen. What the metric does not wait for is the consequence. A fetch started by the handler, and the DOM update that follows when the response arrives, happen in later tasks and are not part of the interaction's latency. INP times the visual acknowledgement, not the outcome.

A page reports one number, and it is the worst interaction of the visit, calculated when the page is left; for pages with more than fifty interactions, one high outlier per fifty is dropped. Across visitors the figure that counts is the 75th percentile of page loads, mobile and desktop reported separately, and the bands are 200 milliseconds or less for good, above 500 for poor. Google Search Central states the same 200 millisecond line and says good Core Web Vitals align with what its ranking systems reward. It is the second row of the table in section two of the audit checklist, and the one row a product page can fail while its load metrics pass. Interactions inside iframes count towards the field figure even though a script on the page cannot see them, so an embedded chat widget or video is measured against the store.

The unit of blame is the task. Web.dev's long tasks guide describes the main thread as the one place where nearly all JavaScript, layout and paint happen, one task at a time; any task over 50 milliseconds is a long task, and the part beyond 50 is its blocking period. A tap that arrives while a long task is running waits for it, and that wait is input delay. A handler that does 300 milliseconds of work before returning is processing duration. Both are the same fact seen from either side: the main thread was busy when the buyer wanted it.

On a product page, the variant picker and the add-to-cart button are the interactions that get measured

Shopify's own INP guidance lists the interactions that are slow on stores in practice: variant selection, opening and closing the cart drawer, expanding the mega menu, typing in search, and applying filters on a collection page. On the product page the first two are the ones on the way to paying, and what each costs depends on the theme.

In Dawn, Shopify's reference theme, a variant change is a change event on the variant-selects element in global.js, which publishes an optionValueSelectionChange message; product-info in product-info.js subscribes to it, disables the submit button, aborts any earlier request still in flight and fetches the product URL with section_id and option_values parameters. That request is the Section Rendering API, which returns the product section rendered for the new selection as HTML. Everything the buyer sees change, the price, the SKU, the inventory line, the gallery, the button text, is rewritten in the then of that fetch: the response is parsed with DOMParser, the price and inventory blocks get their innerHTML replaced, the media gallery is updated, the variant picker itself is swapped by HTMLUpdateUtility.viewTransition, which inserts the new node, hides the old one and removes it half a second later, and a variantChange message goes out to every subscriber.

So the tap itself is cheap in Dawn: the frame the metric times shows a disabled button, and the interaction closes. The expensive part, parsing a section's worth of HTML and rewriting several regions of the page, runs in a later task, when the response lands. That work is not in this interaction's latency. It is in the next one's input delay if the buyer's thumb is already on the way to the add-to-cart button, and every subscriber to variantChange, including any app that listens for it, runs inside the same task. Horizon, Shopify's newer theme, has the same shape in variant-picker.js: abort the previous fetch, request the section, morph the DOM when it returns; and it goes one step further, deferring the history.replaceState call that rewrites the URL to a later task, so that not even the address bar update sits inside the tap.

Add to cart is the same pattern. Dawn's product-form.js handles the submit synchronously by setting aria-disabled, adding a loading class and showing the spinner, then posts to the cart with a sections parameter so the drawer's HTML comes back in the same response. The paint the tap is measured to is the spinner. What matters after that is how the drawer is animated. Shopify's INP page names the fault: a drawer that animates width, height or top triggers layout on every frame, where transform: translateX() runs on the compositor and leaves the main thread free.

Where product pages fail on the tap itself is the code that is not Dawn's. A variant picker in a custom section, or from an app, that holds the whole product's variant JSON in the page and updates price, availability, images and button text synchronously in the handler is doing in the interaction what Dawn does after it, and with a gallery per variant it is a long task. Shopify's guidance for that case is to batch the DOM reads and writes together, so the handler does not force layout between them, and to defer the visual updates to the next frame with requestAnimationFrame. Web.dev's lab guide adds the case where the handler is short and the frame is still late: presentation delay from a large DOM, a forced reflow, or too much work in requestAnimationFrame and ResizeObserver callbacks, which run before the frame can be presented.

App embeds share the thread with the tap, and several of them listen for it

An app that adds a floating or overlaid element, a chat bubble, a badge, a pop-up, arrives as an app embed block. Shopify's theme app extension reference says these are injected before the closing </head> and </body> tags, that a JavaScript file named in the block's schema is added to the head as <script async>, and that an app can restrict its embed to templates with enabled_on; the merchant switches them on under Theme settings, App embeds. A block enabled on every template runs on the product page whether or not anything there uses it.

Two things an app does cost the tap. One is a listener. Shopify's INP page states that analytics scripts listening for click events and running synchronous code inside the handler add processing duration to every interaction, and points at the Event Listeners tab in the Elements panel as the place to see which scripts have attached to the add-to-cart button. The second is a timer. Web.dev's input delay guide singles out setInterval: a callback that runs every few hundred milliseconds in perpetuity is far more likely to be running at the moment the buyer taps than a one-off setTimeout, and the tap waits for it. The guide also notes that breaking up the theme's own tasks with setTimeout does not keep a third-party task from landing in the gap, which is the problem scheduler.yield() was designed to solve.

The cumulative version is the one Shopify's script audit calls death by a thousand cuts: a pixel added by marketing, an analytics tag added by a developer, an app installed for a campaign that ended, each still executing. It rates removing them as the highest-impact optimisation for responsiveness, and notes that uninstalling an app from the admin does not always remove its code from the theme, which is why the check is a search of layout/theme.liquid and snippets/ for the app's name and domain, then the page source. The method for listing every app on the product page and pricing each in milliseconds is in Shopify third-party scripts.

Two costs are not scripts at all. Shopify's stylesheet count page reports collection pages where every product card renders its own <link rel="stylesheet">, 100 to 1,150 separate stylesheets, and style recalculation taking hundreds of milliseconds on every interaction as a result; reducing one production theme from 1,150 stylesheets to 30 cut style recalculation time by 98 percent. And the DOM itself: web.dev's DOM size article gives Lighthouse's thresholds, a warning above 800 nodes and a failure above 1,400, and Shopify's hidden components page measures what a closed filter panel adds, 3,000 to 3,500 nodes, each of which the browser recalculates styles across before it can paint the response to any tap on the page. document.querySelectorAll('*').length in the Console is the count.

Measure it in the field first, then reproduce the tap with the CPU throttled

Run PageSpeed Insights on the best-selling product page, mobile. The top of the report is field data from the Chrome User Experience Report, the 75th percentile over a 28-day rolling window per Shopify's lab and field guide, which also notes that a low-traffic page may show no data of its own and that a fix takes weeks to show. The lab run beneath it does not contain INP at all. Web.dev's definition is plain about why: a lab tool that only loads the page never interacts with it, and Total Blocking Time is offered as a proxy. The TBT threshold is under 200 milliseconds on average mobile hardware; it counts only the blocking time during load, and web.dev says it correlates with INP without being a substitute. The second field source is in the admin: Shopify's testing guide describes the Web Performance Dashboard as real-user data from the store's own visitors, INP alongside LCP and CLS, broken down by page type, device category and region, which is the split PageSpeed cannot give you.

Then the tap. Open the product page in Chrome DevTools and the Performance panel, which per its reference opens on a live metrics view showing the page's three Core Web Vitals as you use it; the lab guide describes the interactions log beneath it, with the INP interaction highlighted and each entry expandable into its three phases. Pick a variant, tap add to cart, open the drawer, open the menu, type in search. Do it again while the page is still loading, because that is when the main thread is busiest. Shopify's page says to do this with CPU throttling set to 4x slowdown in the panel's settings, since the laptop you are testing on is not the phone the 75th percentile is measured on. The Web Vitals extension that used to do this job is at end of life; its repository points to the panel as its replacement since Chrome 132.

When one interaction is slow, record it. Click Record, perform the tap, stop. The Interactions track shows the interaction as a bar with whiskers: the left whisker is input delay, the solid block is processing duration, the right whisker is presentation delay, hovering gives the three figures and a red triangle marks anything over 200 milliseconds. The Main track beneath it shows the tasks, with anything over 50 milliseconds shaded red beyond the fiftieth millisecond; click the long task under the interaction to read which script's function is in it. The Insights tab runs the INP breakdown check, which says which phase to fix: a long input delay means something else was running and the handler is not the cause; a long processing duration means it is; a long presentation delay means the DOM update was the expense.

Without the panel, the Event Timing API reports every interaction to a PerformanceObserver; MDN's reference gives the fields and the 16 millisecond floor on the threshold. Paste this in the Console, then use the page:

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    console.log(
      e.name,
      Math.round(e.duration) + ' ms',
      'delay ' + Math.round(e.processingStart - e.startTime),
      'handlers ' + Math.round(e.processingEnd - e.processingStart),
      e.target
    );
  }
}).observe({ type: 'event', durationThreshold: 16, buffered: true });

Each line is one event with its total, its input delay, its handler time and the element that received it; hovering the element highlights it on the page. For the same attribution from real visitors rather than your own session, web.dev's field guide describes the attribution build of the web-vitals library, whose onINP callback reports the selector of the element behind the page's INP, the interaction type, the three phase durations, and Long Animation Frames entries naming the scripts involved.

Fix it in this order, and it takes a day

First, the scripts that should not be there. Theme settings, App embeds: switch off every embed for an app the store no longer uses, then search the theme code for what uninstalling left behind and check the page source for the app's domain. An hour, no developer, and the highest-impact change on Shopify's own ranking.

Second, the scripts that stay. Select the add-to-cart button in the Elements panel and read the Event Listeners tab; every listener from an app domain is processing duration on the tap. For each, ask whether it needs the product page at all, and whether it can load on interaction instead of at load. An app that cannot be confined is the wrong app.

Third, the theme's own handlers, if the trace puts the time in processing duration or presentation delay. Paint the acknowledgement first, disabled button or spinner, then yield before the work: scheduler.yield() is the API for it, in Chrome since 129 per the long tasks guide, with setTimeout as the fallback, and Horizon's own helper is the pattern to copy. Batch DOM reads before writes. Animate the drawer with transform. Build hidden panels from a <template> on first open. An afternoon for a developer per component.

Fourth, the counts. Stylesheets per page, with skip_styles on any snippet rendered in a loop; DOM nodes, against the 800 and 1,400 lines. Half a day for a developer if the theme is the cause.

Fifth, the live metrics view again, throttled, on the same taps; then the field figure, 28 days after the last change. If it moved and the page still feels slow on a phone, the cause is the load rather than the tap: the four things and the sibling metric in Shopify cumulative layout shift. If you would rather have it measured, ranked against everything else on the store and written up, that is the Store Teardown: €750, five days, and the document is yours whatever you decide to do with it.

Own your growth.Start with the teardown.

Start with a Teardown. Read it, then decide whether you want us to fix what it finds.