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
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/scriptand 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 toafterInteractiveonly when losing pre-loadevents is unacceptable, and permitbeforeInteractiveonly 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
beforeInteractiveones. 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 theloadevent (forlazyOnload). A vendor long task before first paint means abeforeInteractiveassignment survived review. - [ ] 6. Trial the
workerstrategy on the heaviest remaining tag. Enableexperimental.nextScriptWorkersinnext.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
- Next.js Resource Loading Optimization — the parent section for image, font, and route-level scheduling in Next.js
- Fixing Next.js LCP Image Priority with next/image — the sibling deep-dive: fix the LCP resource the scripts were competing with
- Third-Party Resource Impact Mapping — framework-agnostic attribution of vendor cost that feeds this strategy assignment