A non-critical vendor script is marked defer, which keeps it out of the parse phase, and it still lands in a burst of execution immediately before DOMContentLoaded — competing with your own initialisation at the moment the page is trying to become interactive.

Triage: Confirm the Burst Is Real

defer compresses every deferred script into one window. On a page with several of them the result is a burst of long tasks in the same few hundred milliseconds, which is exactly the period that determines whether an early interaction feels responsive.

  1. Record a Performance trace of a cold load with 4× CPU throttling.
  2. Find the DOMContentLoaded marker and look immediately to its left.
  3. Count the Evaluate Script entries in that window and note which are third-party.
  4. Check the Interactions track for any input that landed inside it — those are the samples that will dominate your field INP.
Where deferred scripts actually land A trace showing four deferred scripts all executing in the same window immediately before DOMContentLoaded, producing a burst of long tasks at the moment the page becomes interactive. An interaction arriving in that window is delayed by whichever script is running. Moving two of the four to idle scheduling spreads the work past the window. defer — everything at once\n \n \n \n \n \n DOMContentLoaded\n idle — spread past the window\n \n \n \n \n \n The same four scripts and the same total work. Two of them no longer compete with the first interaction. Which two move is a judgement about dependencies, not about size.

Root Cause: defer Schedules by Phase, Not by Load

The defer attribute makes one promise — execute after parsing, before DOMContentLoaded, in document order — and it keeps that promise regardless of what else the main thread is doing. It is a phase guarantee, not a capacity one. If four scripts are deferred, all four run in that phase, back to back, whether the thread is idle or already saturated with your framework’s hydration.

requestIdleCallback schedules by capacity instead. The browser calls back when it has spare time in a frame, passing a deadline that says how much is available. For a vendor with no load-time dependency that is a strictly better contract: the work happens, but never at the expense of a frame the user is waiting on.

The catch is that “spare time” may never arrive. A page that stays busy — a heavy single-page application, a long animation, a slow device — can starve idle callbacks indefinitely, which turns a deferred script into one that never loads. That is what the timeout option exists for, and omitting it is the single most common defect in this pattern.

Three scheduling contracts compared Three ways to schedule vendor initialisation. The defer attribute guarantees a phase but not capacity, so it runs whether or not the thread is busy. requestIdleCallback without a timeout guarantees capacity but not that it will ever run. requestIdleCallback with a timeout guarantees both: it waits for idle time, but runs anyway once the deadline passes. defer runs in a fixed phase regardless of load predictable, sometimes badly timed rIC, no timeout waits for genuine idle may never run at all a silent failure on busy pages rIC + timeout waits for idle runs anyway at the deadline the only safe form The middle column is where most implementations land, and its failure is invisible in testing. A developer machine is rarely busy enough to starve an idle callback; a mid-range phone is.

Resolution: Idle, With a Deadline and a Fallback

// idle-load.js — schedule a non-critical vendor without starving it.
function onIdle(fn, timeout = 3000) {
  if (typeof requestIdleCallback === 'function') {
    // The timeout is not optional: without it, a permanently busy main thread
    // means the callback never fires and the vendor silently never loads.
    return requestIdleCallback(fn, { timeout });
  }
  // Safari lacked rIC for a long time; a timer is a coarse but adequate stand-in.
  return setTimeout(fn, 200);
}

function loadVendor(src) {
  const s = document.createElement('script');
  s.src = src;
  s.async = true;
  s.fetchPriority = 'low';      // set before append, or it has no effect
  s.crossOrigin = 'anonymous';
  document.head.appendChild(s);
}

// Wait for load, then for idle. Scheduling before load competes with the page.
addEventListener('load', () => onIdle(() => loadVendor(VENDOR_SRC)), { once: true });

Two properties of that code matter more than they look. Scheduling from the load event rather than immediately means the idle callback is not competing with the load itself, which on a busy page is when idle time is scarcest. And fetchPriority = 'low' is deliberate: a script you were happy to defer indefinitely should not be competing for bandwidth with anything the visitor is waiting for.

Verification: The Window Is Clear

Re-record the throttled trace. The window immediately before DOMContentLoaded should contain your own initialisation and no third-party evaluation. The vendor’s Evaluate Script entry should appear later, in a frame with no interaction in it.

What to check in the re-recorded trace Three checks on the trace after the change. The window before DOMContentLoaded contains no third-party evaluation. The vendor evaluation appears later, in a frame with no user interaction. And the vendor still loads within a few seconds on a deliberately busy page, confirming the timeout is working. the DCL window no third-party evaluation your code only later frames the vendor runs here no interaction in the frame a busy page still loads within the timeout the deadline fired The third check is the one people skip, and the one that catches a missing timeout.

Which Vendors Belong on Idle

The pattern is only safe for vendors nothing waits on, and that condition is easier to assert than to verify. A tag can look independent while a piece of code somewhere reads its global — an event handler that calls a tracking function, a conversion page that expects the SDK to be present, a consent callback that assumes a queue exists.

Establish independence empirically rather than by reading the integration guide. Block the vendor’s origin in DevTools, exercise the page’s main flows, and watch the console: any reference error names the code that depends on it. Where a dependency exists, either remove it — routing the call through a queue the vendor drains on load is usually a small change — or accept that this vendor is not an idle candidate.

The vendors that pass this test cleanly are a recognisable set: pixels, session recorders, heatmap tools, retargeting tags, and anything whose only job is to observe. The ones that fail are those the page’s own behaviour is built on, which in practice means A/B testing frameworks and personalisation engines. Those need to run early, and the honest response to their cost is to reduce it rather than to reschedule it — the subject of the worker offloading guide rather than this one.

Common Pitfalls

  • Omitting the timeout. On a page that never goes idle the callback never fires and the vendor never loads — a failure that appears as missing data weeks later rather than as an error.

  • Doing real work inside the callback. The deadline is a budget, not a suggestion. Injecting a script tag is fine; parsing a large payload in the same callback overruns the frame and reintroduces the jank you moved.

  • Applying it to something the page needs. Idle scheduling is for vendors nothing waits on. If any code reads the vendor’s globals, this pattern converts a timing dependency into a race.

  • Scheduling before the load event. Idle time during load is the scarcest it will ever be, so a callback queued at parse time competes with exactly the work you were trying to avoid. Wait for load, then ask for idle.

  • Assuming the callback runs once. requestIdleCallback fires once per registration, but a component that re-registers on every render will queue several. Register at module scope, or guard with a flag.


Frequently Asked Questions

How does this compare with scheduler.postTask?

postTask is the more expressive API: it offers named priority levels, returns a promise, and supports cancellation through a signal, which makes it a better fit when you need to coordinate several pieces of deferred work or abort them on navigation. For the narrow job of “load this vendor when convenient”, the two are close enough that availability decides.

Where both exist, postTask with background priority is marginally preferable because the intent is explicit and the task is cancellable. Keep the idle callback as the fallback rather than replacing it, and keep the timeout in both paths — postTask at background priority has the same starvation property.

What timeout value is appropriate?

Long enough that the callback usually fires on genuine idle time, short enough that a busy page does not delay the vendor past usefulness. Two to three seconds suits most analytics and marketing tags: on a quiet page the idle period arrives in a few hundred milliseconds and the timeout is never reached, while on a saturated one the vendor still loads within a window that keeps its data meaningful.

Derive it from what the vendor is for rather than from a default. A tag that records a pageview is useless if it fires after the visitor has left, which argues for a shorter deadline; a tag that only matters on conversion can afford to wait considerably longer.

Should the script still be marked async?

Yes, on the injected element. A dynamically created script is already asynchronous with respect to parsing, but setting async = true explicitly also opts out of the ordered execution that injected scripts otherwise inherit in some engines — which is what you want for a vendor with no ordering constraint.

The attribute on the original markup is irrelevant here, because there is no longer any markup: the whole point of the pattern is that the element does not exist until the idle callback creates it. If a <script defer> for the same vendor is still in your templates, remove it, or you have both scheduling strategies loading the same file.

Does idle scheduling help Interaction to Next Paint, or only Total Blocking Time?

Both, but by different mechanisms and to different degrees. Total Blocking Time improves directly, because the long task moves out of the measured window. Interaction to Next Paint improves only for interactions that would have collided with that task — which is why the gain is largest on pages where visitors interact early, and near zero on pages they read for thirty seconds first.

The measurement to watch is therefore per-interaction rather than aggregate. If your field data shows most interactions occurring well after load, moving a load-time script to idle will barely move INP no matter how large the script is, and the honest conclusion is that this vendor was never the INP problem. Look instead for vendors with global listeners, which cost on every interaction rather than once.


Up: Async vs Defer: When to Use Each