Choosing the Right next/script Loading Strategy

Field INP sits above 400 ms and LCP regressed the week the marketing tags landed — because every third-party script on the page was mounted with whatever next/script strategy the copied snippet happened to use, not the one its actual urgency justifies.

Root Cause: Strategy Determines Both Fetch Slot and Execution Slot

next/script is not a styling wrapper around <script> — each strategy value places the script into a different position in two separate queues: the network fetch queue and the main-thread execution queue. beforeInteractive serializes the script into the initial server-rendered HTML ahead of the framework’s own bundles, so its fetch competes with critical CSS for the first connection round-trips and its execution runs before hydration begins. A 90 KB tag manager mounted this way does not merely load early; it inserts a parser-adjacent long task between first paint and the moment React can attach a single event handler.

afterInteractive — the default — injects the script client-side once hydration has started. Its fetch no longer competes with the LCP resource in HTML order, but its execution still lands squarely inside the busiest main-thread window of the entire page load: hydration, effect flushing, and the user’s first interactions all contend with it. This is why “we use the default” pages show clean waterfalls yet still fail INP — the cost moved from the network lane to the CPU lane. Mapping which vendor script owns which long task is the discipline covered in third-party resource impact mapping; the strategy prop is the Next.js-native lever you pull once the map exists.

lazyOnload defers fetch and execution until after the window load event, scheduled during browser idle time — the script cannot influence LCP and rarely influences INP, at the cost of arriving seconds later. worker (experimental, powered by Partytown, enabled with nextScriptWorkers: true) removes the script from the main thread entirely: the fetch happens normally but execution occurs in a web worker, with DOM access proxied. The root cause of most regressions is simple mis-assignment across this matrix: consent stubs mounted lazyOnload (breaking tag sequencing), chat widgets mounted beforeInteractive (blocking hydration), analytics mounted in _document as raw <script> tags (bypassing the scheduler altogether).

Strategy Decision Table

Strategy Injection point Fetch timing Executes Main-thread cost window Use for
beforeInteractive SSR HTML, before framework bundles With critical resources Before hydration Pre-hydration (blocks interactivity) Consent stubs, bot detection, required polyfills
afterInteractive (default) Client, at hydration start After HTML parse During/just after hydration The contended hydration window Analytics, tag managers that must catch early events
lazyOnload Client, after load Post-load, idle-scheduled Browser idle time Idle (near zero contention) Chat, social embeds, support widgets, heatmaps
worker Client, via Partytown After hydration In a web worker Near zero (proxy overhead only) Heavy analytics/marketing tags with no layout access

Strategy Timing Against the Page Lifecycle

next/script strategies mapped onto the page load timeline A horizontal timeline from zero to five seconds with markers for FCP at 1.1 seconds, LCP at 1.6 seconds, hydration complete at 2.2 seconds, and the load event at 2.8 seconds. Four bars show fetch and execution windows: beforeInteractive executes before hydration, afterInteractive executes during the hydration window, lazyOnload executes after the load event, and worker executes off the main thread. 0s 1s 2s 3s 4s 5s FCP 1.1s LCP 1.6s hydrated 2.2s load 2.8s beforeInteractive executes pre-hydration — delays interactivity afterInteractive executes inside the hydration window lazyOnload idle — off critical path worker executes in a web worker thread network fetch main-thread execution worker-thread execution

Minimal Reproduction

A tag manager mounted with the strongest strategy available, in the root layout, is the canonical mistake:

// BROKEN: app/layout.tsx — the 92 KB tag container fetches alongside
// critical CSS and executes before hydration, so the first click on any
// route waits behind vendor code.
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://tags.example.com/container.js"
          strategy="beforeInteractive" // wrong tier for a measurement script
        />
      </body>
    </html>
  );
}

The corrected layout splits scripts by urgency instead of loading them uniformly:

// FIXED: only the consent stub keeps the pre-hydration slot; measurement
// rides the hydration window; the chat widget waits for idle.
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        {/* Must exist before any tag fires — the one legitimate pre-hydration script */}
        <Script id="consent-stub" strategy="beforeInteractive">
          {`window.consentQueue = window.consentQueue || [];`}
        </Script>
        {/* Needs early pageview events, tolerates hydration-window execution */}
        <Script src="https://tags.example.com/container.js" strategy="afterInteractive" />
        {/* No load-time role at all — idle scheduling keeps it off both metrics */}
        <Script src="https://chat.example.com/widget.js" strategy="lazyOnload" />
      </body>
    </html>
  );
}

Deterministic Fix Protocol

  • [ ] 1. Inventory every third-party script and its current strategy. Grep the codebase for next/script and raw <script> tags in _document/layout files. Raw tags bypass the scheduler entirely and should be migrated into <Script> first.
  • [ ] 2. Attribute main-thread cost per script. Record a Performance panel trace, open the Bottom-Up tab grouped by domain, and note self-time per vendor origin. Scripts exceeding ~50 ms of pre-interaction execution are INP suspects; the methodology mirrors measuring tag manager blocking time.
  • [ ] 3. Assign each script the weakest strategy that satisfies its data contract. Default every candidate to lazyOnload, promote to afterInteractive only when losing pre-load events is unacceptable, and permit beforeInteractive only for consent/bot/polyfill code. Document the justification inline as a comment.
  • [ ] 4. Verify fetch order in the Network panel. After the change, the LCP image and critical CSS must dispatch before every vendor request except beforeInteractive ones. Any vendor fetch starting in the first ~500 ms that is not on the exception list indicates a stray raw tag or preconnect-triggered early fetch.
  • [ ] 5. Verify execution placement in the Performance panel. Long tasks attributed to vendor domains must now appear after the hydration marker (for afterInteractive) or after the load event (for lazyOnload). A vendor long task before first paint means a beforeInteractive assignment survived review.
  • [ ] 6. Trial the worker strategy on the heaviest remaining tag. Enable experimental.nextScriptWorkers in next.config.js, switch one script, and run its vendor’s own debug/QA mode end-to-end — event delivery, not just absence of console errors, is the acceptance test.
  • [ ] 7. Re-measure INP in the field. Lab TBT will improve immediately; confirm with real-user monitoring over one to two weeks that p75 INP follows, since INP regressions from third parties are interaction-timing dependent and only partially visible in lab traces.

Before/After Metrics

Lab conditions: throttled 4G, 4x CPU slowdown, App Router page carrying a tag manager (92 KB), analytics (28 KB), and a chat widget (210 KB). “Before” is all three on beforeInteractive; “after” is the protocol above with chat on lazyOnload.

Metric All beforeInteractive Tiered strategies Change
LCP 2,940 ms 1,780 ms −39%
Total Blocking Time 1,120 ms 240 ms −79%
INP (lab, first click) 610 ms 140 ms −77%
Hydration complete 3,350 ms 2,150 ms −1,200 ms
Vendor bytes before LCP 330 KB 0 KB −330 KB
Chat widget available 3.4 s 5.1 s +1.7 s (accepted trade)

The one regression — chat availability — is the explicit trade lazyOnload makes, and it is almost always correct: no user opens a support widget 3 seconds into a page view, but every user experiences the first-input delay the widget used to cause.

FAQ

Will lazyOnload make my analytics miss short visits?

Partially, yes: sessions that bounce before the window load event plus the idle callback will never fire the tracker, which typically costs low single-digit percentages of pageviews. If that loss matters, keep the one measurement script on afterInteractive and demote everything else, or buffer early events into a first-party queue (a few lines of inline code) and flush the queue when the vendor script arrives — you keep both the data and the idle scheduling.

When is beforeInteractive actually the right choice?

Only when the script’s absence breaks the first interaction or the render itself: consent-mode stubs that must be defined before any tag executes, bot-detection or experimentation snippets that rewrite the DOM pre-paint, and polyfills the application bundle assumes. It must live in the root layout to take effect, and because it taxes every route’s hydration, each candidate deserves an explicit challenge — most “we need it first” claims dissolve when the vendor’s queueing shim is inspected.

What breaks when I move a script to the worker strategy?

Synchronous DOM and window access. Partytown proxies most property reads and writes across the worker boundary, but code that measures layout geometry, attaches high-frequency scroll or pointer listeners, or calls document.write can fail outright or drift subtly (async proxying changes timing). Migrate one script at a time, run the vendor’s debugger against a staging build, and treat successful event delivery — not console silence — as the pass condition.


Related