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).
What Each Strategy Compiles Down To
The strategy prop is not a scheduling hint the browser understands; it selects one of four concrete emission mechanisms, and knowing which one you get explains every surprising result.
beforeInteractive is the only strategy that touches the server response. The script tag is written into the initial HTML with defer, positioned ahead of the framework’s own bundles, and accompanied by a <link rel="preload"> so the fetch starts with the first batch of discovered resources. Because deferred scripts run in document order before DOMContentLoaded, being emitted first is what guarantees “before hydration” — the mechanism is ordinary async and defer execution ordering, not a Next.js-specific scheduler. That also means the script does not block the parser or first paint. It blocks the framework, which is a different and much more expensive thing.
afterInteractive is emitted by the client runtime. Next.js creates a <script> element during hydration, sets async, and appends it to the document, so the browser fetches at the default script priority and executes on arrival — which is to say, at an unpredictable moment inside the window where React is attaching listeners and running effects. The script never appears in the HTML, so a view-source audit will miss it entirely; only the Network and Performance panels see it.
lazyOnload uses the same client-side injection, but the injection itself is wrapped: next/script waits for the window load event, then defers once more into requestIdleCallback before appending the element. Two gates, not one. This is why lazyOnload scripts can appear several seconds after load on a busy page — idle callbacks only fire when the main thread genuinely drains.
worker swaps the execution environment. The Partytown snippet is inlined, a web worker is spawned, and the vendor script is evaluated there while its DOM and window access is proxied back to the main thread. The fetch is ordinary; only the execution moves.
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
Assigning a Strategy: the Decision Procedure
Do not start from the vendor’s installation instructions — every vendor claims to need the earliest possible slot. Start from the weakest strategy and let three questions promote a script only when the answer forces it. The order matters: the first question is about correctness, the second about data completeness, the third about the DOM contract. Anything that survives none of them stays on lazyOnload.
Two failure modes hide inside question two. A tag that merely wants early pageviews is not the same as a tag that breaks without them, and vendors rarely distinguish the two in their docs. And a script that answers “yes” only because a colleague once saw a missing session in a dashboard deserves a measurement, not a promotion — pull the vendor’s own delivery report for a week on each tier before deciding.
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>
);
}
Worked Example: Demoting a Tag Without Losing Its Early Events
The usual objection to demoting the measurement script is that events fired before it loads are lost. That is true of the vendor script, not of the events — a nine-line inline stub captures them at the pre-hydration moment for 0.4 KB, and the 28 KB vendor bundle replays them whenever it arrives. The same buffering idea, applied outside Next.js, is covered in deferring third-party scripts without breaking analytics.
// app/layout.tsx — the queue is pre-hydration; the vendor code is idle-scheduled.
<Script id="metrics-queue" strategy="beforeInteractive">
{`window.mq = window.mq || [];
window.track = function () { window.mq.push([Date.now(), arguments]); };`}
</Script>
<Script
src="https://metrics.example.com/sdk.js"
strategy="lazyOnload"
onLoad={() => {
// Replay with the original timestamps so session timing survives the delay.
window.mq.forEach(([t, args]) => window.vendorSDK.replay(t, ...args));
window.track = (...args) => window.vendorSDK.send(...args);
}}
/>
Two details make this work rather than merely look like it works. The stub records Date.now() at capture time, so a pageview queued at 400 ms is not reported as having happened at 4.1 s when the SDK finally lands — vendors that reject backdated events are the one case where this pattern fails and the script genuinely belongs on afterInteractive. And onLoad requires a Client Component: in the App Router a <Script> with an event handler must live in a file marked 'use client', which in practice means extracting these two tags into a small <Analytics /> component rather than inlining them in the server-rendered root layout.
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.
What the worker Strategy Actually Costs
worker is the only tier that changes the semantics of the script rather than its position in a queue, so it deserves a mechanical picture before you enable it. Partytown evaluates the vendor bundle inside a web worker, where there is no document and no window. Every property access the vendor code makes is intercepted and forwarded to the main thread, which reads the real DOM and sends the value back. The vendor code sees a synchronous API; the machinery underneath is a round trip.
The trade is explicit: the tag finishes later in wall-clock terms, because every DOM touch is now a round trip instead of a property read, but almost none of that time is charged to the thread the user is trying to interact with. The tags that suit this are the ones that read a handful of values and then talk to the network. The tags that do not are the ones that measure geometry, poll scrollY, or write markup — every one of those turns into hundreds of round trips, and the vendor’s own timings will look catastrophic even though the page feels faster.
Router and Engine Differences That Change the Answer
Four constraints are not visible in the strategy table and routinely invalidate a plan made from it.
worker is Pages Router only. experimental.nextScriptWorkers has no effect on App Router routes; a <Script strategy="worker"> there silently degrades. If the app is on the App Router, the decision tree’s third question has only one usable answer, and the heavy-tag problem must be solved with a facade or a request budget instead.
beforeInteractive only works from one place per router. The root app/layout.tsx in the App Router, pages/_document.js in the Pages Router. Mounted anywhere else it is ignored, and Next.js logs a warning that is easy to miss in a noisy dev server — the script still loads, just on the default tier, which is why “we set beforeInteractive and nothing changed” is such a common report.
requestIdleCallback is not universal history. Chromium and Firefox have had it for years; WebKit only shipped it in Safari 17.4, and Next.js falls back to a setTimeout(cb, 1) shim where it is missing. On an older iOS device the second gate of lazyOnload therefore collapses: the 210 KB chat widget starts parsing one tick after load, right where a user’s first scroll is likely to land. Test the lazy tier on a real low-end device, not only in a desktop Chrome trace where the idle queue is generous.
Repeat views are cheaper than first views, unevenly. V8’s code cache means a beforeInteractive bundle costs materially less to compile on the second visit, so a warm-cache trace can hide a pre-hydration long task that first-time visitors absolutely feel. Judge strategy assignments from a cold profile and confirm with field data segmented by whether the visit was a first view.
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. If the widget must be visible sooner, the answer is not a stronger strategy but a facade: render a 3 KB button immediately and load the real 210 KB bundle on the first click, which keeps the perceived availability at zero seconds and the loading cost at zero milliseconds.
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.
Does beforeInteractive block the HTML parser?
No, and conflating the two costs leads to the wrong fix. Next.js emits these scripts with defer, so the parser runs to completion and first paint is not directly delayed. What they block is hydration: deferred scripts execute in document order before DOMContentLoaded, and the vendor tag is emitted ahead of the framework bundle, so React cannot attach a single listener until the vendor code has finished. Expect the damage in Total Blocking Time and INP. LCP moves too, but indirectly — through bandwidth and connection contention during the first round trips, which is why the metrics table above shows a 39% LCP improvement from a change that is nominally about interactivity.
Why does a lazyOnload script run immediately after a client-side navigation?
Because load fires once per document, and a soft navigation does not create a new one. When a <Script strategy="lazyOnload"> mounts on the second route, next/script sees document.readyState === 'complete', skips the event it can never receive, and schedules the injection on the next idle callback. The practical consequence: the tier that protected your landing page provides almost no protection on route-scoped widgets, since the user is already interacting when the script mounts. Route-level heavy embeds need an interaction trigger or a facade, not a strategy prop.
Should I use next/script for my own first-party code?
Usually not. First-party JavaScript belongs in the module graph, where the bundler can tree-shake it, split it per route, and emit modulepreload hints for its dependency chain — none of which happens for a URL loaded through <Script>. Reserve next/script for code you do not control and cannot bundle. The exception is a genuinely tiny inline snippet that must run pre-hydration, such as a theme or consent bootstrap, where an inline <Script id="…" strategy="beforeInteractive"> is the supported way to reach that slot without hand-writing a tag into the document.
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
- Script Loading: async, defer & Execution Order — the browser-level ordering rules each strategy is built on