next/script afterInteractive Blocking INP on Mobile

next/script strategy='afterInteractive' still pushing INP past 500ms on mobile? Here is the lazyOnload, worker, and onLoad fix that drops it back under 200ms.
next/script afterInteractive Blocking INP on Mobile
PerformanceCore Web VitalsNext.js
May 19, 20266 min read1082 words

The Problem

I ran into this on a client marketing site running Next.js 16 with React 19.2. They had standard third-party tags loaded via next/script: Google Tag Manager, HubSpot tracking, Hotjar, and a chat widget. Every one was set to strategy="afterInteractive", which Vercel's own examples have used for years. After a content refresh that added a few new sections to the homepage, their CrUX field INP went from 180ms (Good) to 540ms (Poor) on mobile.

The pages were not visibly heavier. The number of scripts did not change. The lab scores in PageSpeed Insights were fine: lab INP was 110ms. But the 75th percentile field INP from real users on mid-range Android devices was firmly in the red.

The Long Animation Frames trace from Chrome DevTools showed the culprit. A 380ms LoAF fired directly after the first user interaction, dominated by GTM and HubSpot tag parsing. afterInteractive was queuing those scripts to load right after hydration finished, which on a slow phone overlapped with the first user click.

Why It Happens

afterInteractive means "load the script as soon as possible after the page becomes interactive." On a fast desktop, "as soon as possible" lands well before the user clicks anything, so script load does not compete with input. On a mid-range Android phone, hydration finishes around 1.8 seconds in, and many users start tapping within the next 200 milliseconds. The browser is then asked to:

  1. Run the user's click event handler (React event delegation).
  2. Parse and execute the queued afterInteractive scripts.
  3. Recalculate styles and layout.
  4. Paint the response.

INP is measured from the first input event to the next paint. If steps 2 and 3 fall inside that window, you get a Long Animation Frame and a poor INP score. Multiple scripts queued at the same priority make it worse: GTM alone is around 80kb of parse work, HubSpot is another 60kb, and the chat widget can hit 200kb.

Two further factors made it worse on this site.

First, the GTM container itself loaded GA4, three custom HTML tags, and a Floodlight pixel, all of which run synchronously inside the dataLayer push handlers. The browser sees one gtm.js request but ends up running six scripts back to back.

Second, React 19.2's concurrent input scheduling means hydration runs at lower priority than user input. A click during hydration interrupts hydration, then hydration resumes, then the queued scripts run. The whole sequence stacks inside the INP measurement window.

The Next.js Script docs recommend afterInteractive for analytics but do not warn that it competes with input on mobile.

The Fix

Three layers: downgrade non-critical scripts to lazyOnload, move heavy third-party tags to a Web Worker via Partytown, and use onLoad to defer downstream work that does not have to run on the main thread.

Step 1: Move analytics and chat to lazyOnload. lazyOnload waits for the browser's idle callback after the page has fully loaded, which is well past the INP-critical window:

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          id="gtm"
          strategy="lazyOnload"
          src={`https://www.googletagmanager.com/gtm.js?id=${process.env.NEXT_PUBLIC_GTM_ID}`}
        />
        <Script
          id="hubspot"
          strategy="lazyOnload"
          src="//js.hs-scripts.com/12345.js"
        />
        <Script
          id="hotjar"
          strategy="lazyOnload"
          src="https://static.hotjar.com/c/hotjar-1234.js"
        />
      </body>
    </html>
  );
}

Analytics events that fire in the first 3 seconds will be missed. That is a fair trade for getting INP back to Good. If you cannot afford to lose that early data, use the next step.

Step 2: Run GTM in a Web Worker with Partytown. Partytown intercepts third-party scripts and runs them in a worker thread so they never block the main thread. Install @builder.io/partytown and wire it in the root layout:

// app/layout.tsx
import { Partytown } from '@builder.io/partytown/react';
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Partytown forward={['dataLayer.push', 'gtag']} />
      </head>
      <body>
        {children}
        <Script
          id="gtm"
          strategy="worker"
          src={`https://www.googletagmanager.com/gtm.js?id=${process.env.NEXT_PUBLIC_GTM_ID}`}
        />
      </body>
    </html>
  );
}

strategy="worker" tells next/script to route the script through Partytown. The forward array sends specific function calls (dataLayer.push, gtag) from the main thread to the worker so your existing tracking code keeps working without changes.

Step 3: Defer downstream work with onLoad. If you have code that must run after a third-party script (initialising a chat widget, identifying a user in HubSpot), use onLoad rather than putting an inline <script> after it. onLoad runs after the script loads in the same idle slot, not at hydration time:

<Script
  id="hubspot"
  strategy="lazyOnload"
  src="//js.hs-scripts.com/12345.js"
  onLoad={() => {
    if (typeof window !== 'undefined' && window._hsq) {
      window._hsq.push(['identify', { email: getUserEmail() }]);
    }
  }}
/>

Step 4: Verify with field data, not lab. Lab tools will not show the improvement reliably because they do not model real user interaction timing. After deploying, check CrUX after 28 days, or use the Long Animation Frames API to capture in-session traces from real users:

'use client';
import { useEffect } from 'react';

export function LoAFReporter() {
  useEffect(() => {
    const obs = new PerformanceObserver(list => {
      for (const entry of list.getEntries()) {
        if (entry.duration > 200) {
          navigator.sendBeacon('/api/loaf', JSON.stringify({
            duration: entry.duration,
            startTime: entry.startTime,
            scripts: (entry as any).scripts?.map((s: any) => s.sourceURL),
          }));
        }
      }
    });
    obs.observe({ type: 'long-animation-frame', buffered: true });
    return () => obs.disconnect();
  }, []);
  return null;
}

On the client site, INP dropped from 540ms to 165ms over the following four weeks. The Long Animation Frame after first input shrank from 380ms to 60ms.

The Lesson

afterInteractive was the right default in 2022. On 2026 mobile devices with heavier third-party stacks and stricter INP scoring, it puts script parsing in the same window as user input. Move analytics to lazyOnload, move GTM to a worker via Partytown, and defer downstream initialisation with onLoad.

If your INP regressed after a content or tracking refresh and lab tests do not show the cause, that is the kind of work I do. See my services. For a related third-party-tag INP debugging case, see INP regression from GTM third-party tags.

Back to blogStart a project