You loaded analytics through next/script with strategy="afterInteractive", yet a Performance trace still shows the vendor’s evaluation and event handlers eating 150–300 ms of main-thread time during the interactive window, and your field INP has crept above 200 ms.

Triage: Confirming the Script Runs on the Main Thread

afterInteractive moves when a script runs; it does nothing about where. Confirm the main thread is the bottleneck before changing anything.

  1. Record a throttled main-thread trace. Open DevTools → Performance, enable 4× CPU throttling, and record a load plus a couple of interactions. In the Main track, look for tasks attributed to the vendor’s script URL.

  2. Attribute the long tasks. Expand any task over 50 ms and read the call stack. If the top frames are the vendor bundle (e.g. gtm.js, analytics.js) evaluating or handling pushState/event callbacks, that work is on the main thread and directly inflating Total Blocking Time and INP.

  3. Confirm the strategy in use. In the React tree or source, verify the <Script> is afterInteractive (or the default). This tells you the script is deliberately scheduled post-hydration but not offloaded.

  4. Run the reproduction checklist:

Root Cause: afterInteractive Still Executes on the Main Thread

The afterInteractive strategy schedules the script to inject and run just after hydration, but it runs in the page’s main JavaScript context like any other tag. A heavy vendor bundle parses, evaluates, and then attaches long-lived event and history listeners — all competing with React and with user input for the single main thread. This is the exact cost noted in the guide to isolating scripts with the Next.js Script component: strategy controls timing, not thread.

The fix is to change where the code runs. Next.js ships a built-in integration with Partytown behind strategy="worker", which relocates the third-party script into a web worker. Partytown runs the vendor code inside the worker and proxies its document/window/localStorage access back to the main thread through a service worker using synchronous XHR, so the script believes it is running normally while the main thread stays free. This is Next.js’s opinionated path to the general technique described in offloading heavy scripts to web workers.

The mechanism is worth understanding before configuring it, because its constraints follow directly from how it works: the vendor’s code runs in a worker that has no DOM, and every DOM access it makes is proxied back to the main thread synchronously.

How a proxied DOM access travels The vendor script runs inside a worker. When it reads or writes something on the main thread — document cookie, a DOM node, a global — the access is intercepted, sent to the main thread via a synchronous channel, executed there, and the result returned. Each access therefore costs a round trip, which is why a script that touches the DOM constantly performs worse in a worker than on the main thread. worker vendor code runs here main thread the real DOM lives here document.cookie → proxied value returned synchronously Every DOM access is a round trip. A chatty script gets slower in a worker, not faster. Good candidates touch the DOM rarely and compute a lot: analytics collectors, not UI widgets. The proxy needs a same-origin reverse proxy for cross-origin scripts, which is the main setup cost.

Resolution: strategy=“worker” Plus the Partytown Setup

Three pieces are required: the feature flag, the Partytown package, and the strategy change. Miss any one and the script silently falls back to the main thread or fails to load.

1. Enable the experimental worker strategy in next.config.js.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    // Required for strategy="worker" to relocate scripts into Partytown.
    nextScriptWorkers: true,
  },
};

module.exports = nextConfig;

2. Install Partytown. Next.js does not bundle it; the worker strategy expects it as a peer.

npm install @builder.io/partytown

On next dev or next build, Next.js copies the Partytown library into /_next/static/~partytown/ and registers the service worker that proxies DOM access. No manual <Partytown /> component is needed when you use strategy="worker" — the framework wires it.

3. Switch the script to strategy="worker". Only the strategy value changes; the rest of the component is identical.

// app/analytics.tsx
'use client';
import Script from 'next/script';

export function Analytics() {
  return (
    <>
      {/* This bootstrap runs on the main thread — it only queues commands. */}
      <Script id="ga-bootstrap" strategy="worker">
        {`window.dataLayer=window.dataLayer||[];
          window.gtag=function(){dataLayer.push(arguments);};
          gtag('js', new Date());
          gtag('config', 'G-XXXXXXXXXX');`}
      </Script>
      {/* The heavy vendor bundle now evaluates inside the Partytown worker. */}
      <Script
        src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
        strategy="worker"
      />
    </>
  );
}

4. Mind the DOM-access caveats. Partytown proxies document and window, but proxying is synchronous and comparatively slow, and a few APIs do not survive the round trip:

  • Scripts that read layout (getBoundingClientRect, offsetWidth) or mutate the DOM on a hot path will be sluggish or visibly wrong — do not offload widgets, maps, or anything that paints.
  • Direct DOM event listeners attached in the worker are proxied; latency-sensitive handlers (drag, scroll, input) belong on the main thread, not in Partytown.
  • Some SDKs feature-detect by checking for real window properties and refuse to run in a proxied context; test each vendor rather than assuming.
  • If the vendor needs to reach globals your first-party code sets (e.g. a dataLayer), you may need data-partytown-config forward entries so calls are relayed. For gtag/GTM the standard forward is dataLayer.push.

Restrict worker to pure measurement scripts — analytics, tag managers firing events, conversion pixels. Everything with a synchronous visual dependency stays on afterInteractive or lazyOnload.

Verification

Record the same throttled Performance trace after the change. The decisive check: the vendor script’s evaluation and event work now appears under a Worker track (or a partytown service-worker task), and the Main track no longer shows a longtask attributed to the vendor URL during the interactive window. Confirm in DevTools → Application → Service Workers that the Partytown worker is activated, and in Network that requests to /_next/static/~partytown/ succeed. In the field, INP should fall back under 200 ms once the main-thread contribution of the offloaded bundle is gone, while analytics events still arrive in the vendor dashboard.

Choosing candidates well is most of the work. The strategy helps a narrow class of script and actively harms another, and the distinction is visible before you try it.

Which scripts belong in a worker Good candidates read and write the DOM rarely while doing substantial computation or network work: analytics collectors, error reporters, and product-feed processors. Poor candidates interact with the DOM constantly or must respond to input synchronously: UI widgets, chat launchers, and anything that renders. A middle group needs testing, since behaviour depends on how the vendor is written rather than on its category. good candidates analytics collectors error reporters, feeds test before trusting tag managers depends how they are written poor candidates anything that renders chat, UI widgets, players When in doubt, measure both placements — the answer is specific to the vendor, not the category.

Finally, treat this as a reversible experiment rather than an architecture decision. Keep the strategy behind a flag for the first release so it can be turned off without a deploy, and compare the vendor’s own reported volume across a full week rather than a single session — proxy failures are frequently partial, affecting one event type or one code path, and a same-day comparison will not surface them.

It is also worth setting expectations about maintenance: the proxy has to keep pace with the vendor. A vendor that starts using a browser API the proxy does not forward will break silently on their release schedule rather than yours, which makes this the one third-party integration where subscribing to the vendor’s changelog is genuinely worthwhile.

Common Pitfalls

  • Offloading a script that touches layout or paints. Partytown’s synchronous DOM proxy is slow; a maps embed, carousel, or chat widget offloaded to the worker will jank or misrender. Keep those on the main thread and offload only measurement code.
  • Forgetting the nextScriptWorkers flag or the package. Without experimental.nextScriptWorkers in next.config.js, strategy="worker" is ignored; without @builder.io/partytown installed, the build cannot copy the library and the script fails to run at all. Both are required together.
  • Assuming every vendor works off-thread. Some SDKs feature-detect a real window and refuse to initialize under Partytown, or depend on cookies written synchronously. Verify each vendor in a trace and dashboard before shipping, and fall back to afterInteractive for any that misbehave.
What to measure before and after Three measurements taken in both configurations. Total blocking time should fall, which is the reason for the change. The vendor’s own event volume should be unchanged, since a drop means the proxy is breaking its behaviour. And the total main-thread time including the proxy should be lower, not merely relocated, which is the check that catches a chatty script. blocking time expect: down the reason for the change vendor's event volume expect: unchanged a drop means it broke total main-thread time expect: down, not moved catches a chatty script The middle one is the check teams skip, and the one that catches a silently degraded vendor.

Frequently Asked Questions

Why does the vendor stop recording events after the switch?

Almost always because something the vendor relies on is not reachable through the proxy. The common cases are direct document.cookie writes with attributes the proxy does not replicate, references to globals set by other scripts that are not themselves in the worker, and synchronous access to APIs that have no proxied equivalent.

Diagnose by moving one vendor at a time and comparing the vendor’s own reported volume before and after, not by reading your own logs. A proxy failure is usually silent — the script runs, throws nothing, and simply collects less — so only the vendor’s numbers reveal it.

Is a same-origin reverse proxy really required?

For cross-origin vendor scripts, yes. The worker fetches the script itself, and a cross-origin response without permissive CORS headers cannot be read — so the vendor’s bundle has to be served through a path on your own origin. That is a genuine piece of infrastructure, not a configuration flag, and it is the main reason this strategy is more work than it first appears.

It also has a side effect worth planning for: proxied scripts appear under your own hostname in resource timing, so your vendor attribution has to match on path rather than host or the cost will be credited to first-party code.


Up: Isolating Scripts with the Next.js Script Component