Problem
A client's ecommerce site held a green INP of 120ms through March. Their marketing team added three new GTM tags in April — a heatmap vendor, a consent-mode update, and a new conversion pixel. CrUX latest report: INP at 340ms on mobile, above the 200ms "good" threshold, and the SEO lead saw rankings drop on two high-value product pages within ten days.
Lab scores in Lighthouse still showed 180ms. Only real-user monitoring was flagging it. If your INP regressed on CrUX after GTM changes but your lab runs look clean, this is the pattern to check for first.
Why It Happens
Interaction to Next Paint measures the time between any user interaction (tap, click, keypress) and the next frame the browser paints after running all the handlers that interaction triggered. Any long task on the main thread during that window gets attributed to the interaction, even if the task was scheduled by something completely unrelated.
Google Tag Manager is particularly bad for this because:
- GTM runs tags on a single synchronous chain by default. Every custom HTML tag, every image pixel, every third-party script injected through a tag loads on the main thread and blocks the event loop until parsed. If a user taps "Add to Cart" while GTM is parsing a 90KB vendor bundle, the interaction waits for the script to finish before the DOM update paints.
gtag.jsanddataLayerlisteners rebuild on every push. A singledataLayer.push({event: "add_to_cart"})triggers GTM to re-evaluate every trigger in the container. With 60+ tags in a typical ecom container, that is a 50–120ms synchronous block on the interaction itself.- Consent mode v2 runs a second evaluation pass. Every interaction that updates consent fires the full trigger evaluation twice — once for default-denied state and once for granted. On a mid-range Android, that can double the interaction cost.
The new tags in my client's case were a customer reviews script (synchronously eval'd 140KB of JSON), a Hotjar alternative (attached a MutationObserver on every click), and a duplicated GA4 config tag firing the same event twice. Together they pushed three of the top five interaction handlers over 200ms.
PageSpeed Insights will not always show this because lab runs warm the cache and avoid a cold DNS resolve on vendor origins. Real users hit it fresh.
The Fix
Three moves in order of effort and impact. Ship them sequentially and re-measure after each — do not do them all at once or you will not know which one actually helped.
1. Find the tag that is actually blocking.
Open Chrome DevTools, go to the Performance panel, start recording, tap a button that feels slow, stop recording. Look at the "Interactions" track for a red bar. Expand it. The "Call Tree" will show the script URL and the evaluation time.
// Quick console probe — counts real-user long tasks during interactions
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === "longtask" && entry.duration > 50) {
console.warn("long task", entry.duration.toFixed(0) + "ms", entry.attribution);
}
}
});
po.observe({ type: "longtask", buffered: true });
Paste that into the console on your live site, click around, and any task over 50ms logs with its attribution (usually a script URL). I ship it as a one-off debugging snippet, never in production. If three interactions all attribute to googletagmanager.com/gtm.js, you know which container to audit.
2. Move non-essential GTM tags off the main thread with Partytown.
Partytown runs third-party scripts in a web worker so they cannot block your main thread at all. It is the single biggest INP win I know of for GTM-heavy sites. Install and wire it into a Next.js project:
// app/layout.tsx
import Script from "next/script";
import { Partytown } from "@qwik.dev/partytown/react";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Partytown debug={false} forward={["dataLayer.push", "gtag"]} />
<Script
id="gtm"
type="text/partytown"
dangerouslySetInnerHTML={{
__html: `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
The type="text/partytown" attribute is what tells the library to execute the script in the worker instead of the main thread. forward lists the globals (dataLayer, gtag) that Partytown proxies back to the worker when your app code calls them on the main thread.
Partytown is not a silver bullet. Anything that needs direct DOM access (heatmaps, some session recorders) will not work inside a worker. For those, skip to step 3.
3. Audit the container and defer what you can.
In GTM, open each tag and check "Tag firing options". Set non-essential tags (analytics, heatmaps, pixels) to "Once per page" and move their triggers to Window Loaded instead of All Pages. For the conversion tag on a cart page, use Custom Event - purchase rather than re-firing on every interaction.
Remove duplicated GA4 config tags. The usual cause of double-counting is exactly what I covered in GA4 double counting in App Router, and it compounds INP because both copies fire on every click.
For scripts that must stay inline, wrap the push in requestIdleCallback:
// lib/analytics.ts
type GtagEvent = { event: string; [key: string]: unknown };
declare global {
interface Window {
dataLayer: GtagEvent[];
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
}
}
export function trackEvent(payload: GtagEvent) {
const push = () => window.dataLayer.push(payload);
if (typeof window.requestIdleCallback === "function") {
window.requestIdleCallback(push, { timeout: 2000 });
} else {
setTimeout(push, 0);
}
}
Replace every dataLayer.push(...) call in your app with trackEvent(...). The event still fires, but it waits until the browser is idle, so it never competes with a click handler for the same frame. On the client site I rolled this out to, INP dropped from 340ms to 160ms over 28 days without losing a single tracked conversion.
Gotchas
Safari iOS does not support requestIdleCallback. The fallback to setTimeout(fn, 0) is fine for analytics but not for anything time-critical.
Partytown breaks Consent Mode v2 defaults. If you rely on consent defaults running before GTM loads, set them inline in the document head above the Partytown bootstrap so they apply before the worker is ready.
RUM beats lab for this. Your Lighthouse INP will lie. Use the CrUX report in Search Console or the PageSpeed Insights field data as the source of truth. For the broader Core Web Vitals playbook, my Core Web Vitals guide for 2026 covers LCP, CLS, and INP measurement end to end.
Need Your INP Back Under 200ms Before Rankings Slip?
I diagnose INP regressions on live ecommerce and marketing sites using real-user data, move the right tags off the main thread, and ship the fix with proper before/after CrUX measurement. If your Search Console is flagging INP, send me the URL on my services page and I'll pinpoint the blocking tag inside 48 hours.