A vendor tag is marked afterInteractive, it evaluates for 180 ms immediately after hydration, and moving it to lazyOnload would remove that from the interaction window — if nothing on the page depends on it, which nobody is certain about.
Triage: Establish the Dependency Empirically
The choice is a dependency question, and dependency claims from integration guides are unreliable. Test it.
- In DevTools → Network, right-click the vendor request and choose Block request URL. Reload.
- Exercise the page’s main flows: navigation, a form, a filter, whatever the product does.
- Watch the console. Any
ReferenceErrororTypeErrornaming the vendor’s global identifies code that depends on it. - Note when each error occurs. A failure during hydration is a hard dependency; one on a rare interaction is a soft one.
Root Cause: The Strategies Differ in Timing, Not in Cost
afterInteractive injects the script immediately after hydration completes. lazyOnload waits for the load event and then for idle time. Neither reduces the script’s size or its execution cost; both place identical work at different points, and the difference matters only because one of those points overlaps the window in which early interactions occur.
That makes the choice a question about dependencies rather than about performance. A script nothing waits on can be moved later at no functional cost and a real metric benefit. A script something waits on cannot, and moving it converts a timing property into a race — usually one that passes in development, where everything is fast, and fails intermittently in production.
The complicating case is a tag container. Moving a container to lazyOnload moves every tag inside it, including any your own code expects to exist, and the container’s contents change without a deploy. A dependency test performed today can be invalidated by a marketing change tomorrow, which argues for keeping containers at afterInteractive and moving individual vendors instead where the platform allows.
Resolution: Move What You Can, Prove It Still Works
// Analytics that nothing waits on: move it past the interaction window.
<Script
src="https://cdn.vendor.example/analytics.js"
strategy="lazyOnload"
onError={(e) => report('vendor.load.failed', { vendor: 'analytics', e })}
/>
// A container whose contents you do not control: leave it early.
<Script
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"
strategy="afterInteractive"
/>
Where a piece of your own code does read the vendor’s global, the dependency is usually removable rather than fundamental. Most vendors accept queued calls before their SDK loads — an array the bundle drains on initialisation — which converts a hard dependency into a soft one and unlocks the later strategy:
// Queue instead of calling directly. The SDK drains this when it loads,
// whenever that turns out to be.
window.vendorQueue = window.vendorQueue || [];
export function track(event, props) {
window.vendorQueue.push(['track', event, props]);
}
That indirection is worth adding even if you keep afterInteractive today. It removes the timing coupling permanently, so the strategy becomes a performance decision you can revisit rather than a constraint fixed by whichever call site happened to read the global directly.
Verification: The Window Is Clear and Nothing Broke
Re-record a throttled trace and confirm the vendor’s Evaluate Script entry no longer appears between hydration and the load event. Then exercise the page as in triage and confirm the console is clean — the behavioural half is not optional, because the failure mode of this change is silence rather than an error.
Watching for Drift After the Change
A dependency test is a snapshot. The page that has no dependency on a vendor today acquires one the moment someone adds a call that reads its global directly, and nothing in the build will object — the code works in development because the script has loaded by the time anyone interacts.
Two cheap guards keep the property. A lint rule forbidding direct reads of vendor globals outside the queue module makes the dependency impossible to reintroduce accidentally, and it fails at review rather than in production. And keeping the blocked-vendor exercise from the triage step as an occasional manual check — or as a headless test that blocks the origin and asserts the console is clean — catches anything the lint rule cannot express.
Neither is elaborate, and the reason to bother is that this particular regression is invisible. A page with a new hard dependency on a lazyOnload script behaves correctly nearly always and fails on slow connections for the visitors least able to tolerate it, which is the worst possible distribution for a defect.
Measuring the Change Honestly
The change is easy to over-claim. Moving a script from one strategy to the other does not reduce any total — the bytes are identical, the execution cost is identical, and a metric that sums main-thread time over a whole session will not move at all.
What moves is Total Blocking Time, because it is measured over a window the work has now left, and Interaction to Next Paint for the subset of visitors who interacted during that window. That subset is the whole benefit, and its size varies enormously by site: a page people read for thirty seconds before touching anything gains nearly nothing, while a search results page people start filtering immediately gains a great deal.
Measure that subset before and after rather than the aggregate. If your field data shows most interactions occurring well after load, the honest conclusion is that this vendor was never your interaction problem — and the effort is better spent on vendors that install global listeners, which cost on every interaction regardless of when the script loaded.
Common Pitfalls
-
Moving a container. Everything inside moves with it, including tags added later by people who do not know the strategy changed.
-
Testing the dependency in development. Everything loads quickly locally, so a race that would fail in production passes.
-
Assuming no error means no dependency. Many vendors fail silently when called before initialisation, producing lost data rather than a thrown exception.
-
Changing the strategy without re-checking after tag changes. The dependency set of a container is not stable.
-
Moving several scripts at once. If something breaks, nothing identifies which one. Move one per release until the pattern is established.
-
Leaving
beforeInteractivein place elsewhere. A page with one script blocking hydration gains little from moving another one later; audit the whole set rather than the loudest entry.
Frequently Asked Questions
Is lazyOnload the same as loading on idle?
Close, and with one important difference: it waits for the load event first, and then for idle time, which means it never runs before the page has finished loading its own resources. A raw idle callback registered early can fire during load if a gap appears, which is exactly when idle time is least useful.
The practical consequence is that lazyOnload is the more conservative of the two and usually the right default for a vendor with no dependency. Where you need finer control — a timeout, cancellation on navigation, a priority — the idle scheduling pattern gives it to you at the cost of writing the injection yourself.
Does the strategy affect a script's fetch priority?
Indirectly and unhelpfully. A script injected after hydration is a dynamically created element, which browsers classify at Low priority regardless of the strategy that injected it — so both afterInteractive and lazyOnload produce a low-priority fetch, and the strategy changes when the fetch starts rather than how it is ranked.
For lazyOnload that is entirely appropriate: a script you were willing to defer indefinitely should not compete with anything. For afterInteractive on a script that genuinely matters, the component does not expose a priority control, which is one of the cases where hand-rolled injection is worth the extra code.
What happens on a client-side navigation?
The script is not re-executed, because the document was never replaced — the element is deduplicated by source, and any globals it created persist for the session. That is usually what you want, and it means a lazyOnload script loaded on the first page is already present on the second.
Where it surprises people is a vendor expecting a per-view initialisation. The script will not run again, so a pageview beacon tied to script execution fires once for a whole session. Use the component’s onReady callback, which fires on every mount including navigations, for anything that must happen per view — as distinct from onLoad, which fires only the first time.