Reducing Input Delay from Third-Party Scripts
Input delay is the part of an interaction where the user has already acted and the browser cannot yet run your handler, because something else owns the main thread. On most commercial sites that something else is a tag: analytics, consent, chat, A/B testing, ad stack. This guide sits under INP Tracking & Debugging and covers how to attribute delay to a specific third party in field data, and what actually works once you know which one it is.
The diagnostic value of splitting input delay out is that it changes who fixes the problem. A 400 ms INP made of 40 ms delay and 340 ms processing is your handler. The same 400 ms made of 310 ms delay and 60 ms processing is not your code at all, and no amount of optimising your click handler will move it.
Prerequisites
- INP attribution in the field, split into input delay, processing duration and presentation delay — the setup is in Debugging Web Vitals with the Attribution Build.
- A Long Animation Frames or Long Tasks observer running, as described in Long Task & Main-Thread Attribution.
- A list of every third-party script the page loads, including the ones injected by other third parties.
- Permission to change tag loading. This is usually the real blocker, and it is worth securing before you start measuring.
How to attribute and remove the delay
Step 1 — Split INP and find the delay-dominated interactions
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const a = metric.attribution;
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'INP',
value: Math.round(metric.value),
inputDelay: Math.round(a.inputDelay),
processing: Math.round(a.processingDuration),
presentation: Math.round(a.presentationDelay),
target: a.interactionTarget,
type: a.interactionType,
route: window.__route,
// Bucket for the query below: delay-dominated interactions are the
// ones a third party is most likely responsible for.
delayShare: metric.value ? Math.round((a.inputDelay / metric.value) * 100) : 0,
}));
}, { reportAllChanges: false });
Query for interactions where delayShare exceeds 50%. On a page with heavy tags this is often a third of all slow interactions, and it is a population your own code cannot fix.
Step 2 — Name the script holding the thread
Long Animation Frames give you the script attribution that Long Tasks never did.
// LoAF names the script, character offset and invoker for the work that
// occupied the frame — enough to identify a third party by URL.
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.duration < 100) continue;
for (const script of frame.scripts || []) {
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'loaf_script',
value: Math.round(script.duration),
// sourceURL is the third-party origin you are looking for.
source: script.sourceURL,
invoker: script.invoker,
type: script.invokerType,
frameStart: Math.round(frame.startTime),
}));
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
Group by the origin of sourceURL and rank by total duration. The ranking is nearly always shorter than people expect: two or three origins account for most of the blocked time.
Step 3 — Choose the right intervention per tag
Not every tag can be treated the same way, and “defer everything” breaks consent and analytics in ways that get noticed.
| Tag type | Safe intervention | What breaks if you get it wrong |
|---|---|---|
| Analytics | Load after first paint; queue events until ready | Lost early events; use a stub queue |
| Consent management | Cannot defer — it gates everything else | Legal exposure; optimise its own weight instead |
| A/B testing | Inline only the decision, defer the SDK | Flicker, or the test does not apply |
| Chat and support | Load on interaction (click on a launcher stub) | Nothing — this is nearly always safe |
| Ad stack | Isolate in an iframe; reserve space | Revenue; coordinate with the ad team |
| Tag manager | Audit its contents; most cost is inside it | It re-adds what you removed |
The chat-widget pattern is worth stating explicitly because it is the highest-value, lowest-risk change on most sites: render a static launcher button in your own markup, and load the vendor SDK only when it is clicked. The widget appears identical and its several hundred kilobytes never touch a session that did not want it.
// Load a heavy widget only when the user asks for it.
const launcher = document.querySelector('[data-chat-launcher]');
let loading = false;
launcher?.addEventListener('click', async () => {
if (loading) return;
loading = true;
launcher.setAttribute('aria-busy', 'true');
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://chat.example.com/sdk.js';
s.async = true;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
window.ChatSDK.open();
launcher.removeAttribute('aria-busy');
}, { once: false });
Step 4 — Give the remaining tags a lower priority
Scripts you cannot remove can still be made to wait. async frees the parser but still runs as soon as it arrives; defer waits for parsing to finish; loading inside requestIdleCallback after the first interaction is later still.
// Load non-critical tags when the main thread is genuinely idle, with a
// deadline so they still run on pages that never go idle.
function loadWhenIdle(src, timeout = 4000) {
const load = () => {
const s = document.createElement('script');
s.src = src;
s.async = true;
document.head.appendChild(s);
};
if ('requestIdleCallback' in window) requestIdleCallback(load, { timeout });
else setTimeout(load, 1200);
}
Step 5 — Re-measure the delay share, not the total
The number that proves this worked is the input-delay share of INP on the affected route. The total INP will also move, but it moves for many reasons; the share isolates your change.
Verifying it works
- Delay share on the affected route should fall below about 30% of the interaction. That is the point at which your own handler becomes the thing worth optimising.
- LoAF script ranking should no longer show the third party in the top three by total duration during the first ten seconds of the page.
- Tag functionality, checked explicitly: events still recorded, experiments still applied, consent still gating. A performance win that silently breaks analytics is discovered a month later by someone else.
- No regression in LCP. Deferring scripts frees bandwidth and main thread, so LCP normally improves; if it got worse, something you deferred was preloading the hero.
Making the case to the people who own the tags
The measurement is usually the easy part. The change requires agreement from marketing, analytics or legal, and a performance argument alone rarely carries it. Three framings work better than a percentile.
Cost per tag, in the metric everyone already agreed to. “This tag adds 154 ms of input delay at p75 on the product route” is concrete, attributable and comparable across tags. Publish the ranking rather than arguing about one vendor.
Cost in the currency of the owner. Where you have the funnel data from Conversion Funnel Correlation, express the delay as a modelled effect on completion rate for the step where the tag runs. That converts a performance number into the number the tag owner is measured on.
A reversible experiment. Ship the deferral behind a flag to a fraction of traffic for a week and compare both the interaction metric and the tag owner’s own metric. An outcome nobody has to take on trust ends most of these debates in one cycle.
Edge cases and gotchas
- Tag managers re-add what you removed. Auditing the page source is not enough — audit the container. Most of the weight is usually inside it, added by people who do not deploy your code.
asyncis not deferral. An async script that arrives during the first interaction runs during the first interaction. For anything non-critical, later is better than parallel.- Consent scripts genuinely cannot be deferred. They gate everything downstream, so the only lever is their own size and execution time. Measure them; take it to the vendor.
- Third parties that inject third parties. An ad tag that loads four more origins is a tree, not a script. LoAF attribution shows you the leaves, which is where the time usually goes.
- Safari reports no INP. Interaction data on iOS comes from Event Timing without
interactionIdfor some input types, so this whole analysis is Chromium-weighted. Segment by browser before extrapolating. - Removing a tag changes the traffic mix in your analytics. If you defer the analytics script itself, early-abandoning sessions stop being recorded and every downstream metric shifts. Use a stub queue instead of deferring collection.
FAQ
How do I know the delay is a third party and not my own bundle?
Long Animation Frames name the script sourceURL for the work that occupied the frame. If the top entries are your own bundle, the problem is yours — and the fix is yielding, covered in Breaking Up Long Tasks with scheduler.yield.
Does putting a script in an iframe fix input delay?
Partly. A cross-origin iframe gets its own event loop in browsers with site isolation, which protects your main thread from its script execution — but layout and paint are still shared, and the iframe still competes for bandwidth.
Is requestIdleCallback safe for analytics?
With a timeout, yes. Without one, a page that never goes idle never loads the tag. Always pass a deadline, and always queue events in a stub so nothing recorded before the SDK arrives is lost.
Which single change usually helps most?
Loading the chat or support widget on interaction rather than on load. It is typically the largest deferrable payload on the page and the one with the fewest dependencies.
Related
- INP Tracking & Debugging — thresholds, capture and the wider debugging workflow.
- Breaking Up Long Tasks with scheduler.yield — the fix for the processing half of the same metric.
- Long Task & Main-Thread Attribution — the observers that name the script holding the thread.