Third-Party Scripts and Animation Jank
Part of Main-Thread Scheduling & Long Animation Frames in Performance Budgeting & GPU Architecture.
The problem
The team optimises a hero entrance until it is compositor-only and smooth in every lab test. In production it stutters for about a second after load on mid-range phones. Nothing in the application’s own code runs during that window. A field trace shows a long animation frame attributed to a tag manager, which was loading an experiment framework, which was reading layout to apply a variant.
Third-party scripts share the main thread with your animations, and by default they arrive at the worst possible moment.
Root cause analysis: someone else’s work, your frame budget
Third-party code runs on the same thread. A tag manager, chat widget, session recorder or experiment tool executes in your page’s main thread. Its parsing, execution and any layout reads it performs occupy the same 16.7ms that your animation’s first frames need.
They often load early and eagerly. Snippets are commonly pasted in <head> with async, so they arrive during the busiest phase of the page’s life — exactly when entrance animations run.
Layout reads are the expensive part. Session recorders and experiment tools frequently measure elements, which forces synchronous layout inside their scripts. Long animation frame entries expose that as forcedStyleAndLayoutDuration, as described in debugging jank with the Long Animation Frames API.
You cannot rewrite them, but you can schedule them. Loading order, timing and isolation are under your control even when the code is not.
Attribution is the argument. “The chat widget costs 180ms of blocking time during our entrance animation on mid-range Android” is a conversation with a vendor or a product owner. “The site feels slow” is not.
Step-by-step resolution
Production code pattern
// Load non-critical third parties after the page has settled and animations are done.
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
function loadTag(src, { attrs = {} } = {}) {
const s = document.createElement('script');
s.src = src;
s.async = true;
for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v);
document.head.append(s);
return s;
}
function afterEntranceAnimations() {
const running = document.getAnimations()
.filter((a) => a.effect?.getComputedTiming().iterations !== Infinity);
return Promise.allSettled(running.map((a) => a.finished));
}
addEventListener('load', async () => {
await afterEntranceAnimations(); // never compete with the hero entrance
await new Promise((r) => (self.requestIdleCallback ?? setTimeout)(r, { timeout: 3000 }));
loadTag('https://example-analytics.test/a.js');
loadTag('https://example-chat.test/widget.js', { attrs: { 'data-defer-ui': 'true' } });
});
// Facade: render a cheap placeholder and load the real widget on demand.
chatButton.addEventListener('click', async () => {
chatButton.disabled = true;
await import('/chat-loader.js').then((m) => m.open());
}, { once: true });
/* The entrance animation does not depend on any third-party code. */
.hero { animation: hero-in var(--motion-enter-duration) var(--motion-enter-easing) backwards; }
@keyframes hero-in { from { opacity: 0; translate: 0 12px; } }
@media (prefers-reduced-motion: reduce) {
.hero { animation: none; }
}
Rendering Impact: none added. Deferring third-party execution removes competing main-thread work from the frames where entrance animations run; the facade pattern replaces a heavy widget with a button until the user wants it.
The facade is the highest-leverage pattern for chat, video embeds and map widgets: a lightweight placeholder that looks like the widget and loads the real one on click. It removes the entire script from the load phase, and because most users never open the widget, most sessions never pay for it at all.
Isolation and sandboxing
Some third parties can be moved off the main thread entirely. Worker-based sandboxes execute third-party code in a worker and proxy DOM access, which is effective for analytics and tag managers whose DOM use is light, though it is not transparent — code that measures layout synchronously may behave differently. iframe isolation is cruder but reliable for self-contained widgets: an embedded chat or map in an iframe has its own event loop, so its work does not occupy your frames, at the cost of communication overhead and duplicated resources.
Neither technique helps with an experiment tool that must change your DOM before first paint. For those, the right answer is usually to move variant selection to the server so no client-side script blocks rendering, which also removes the flash of original content.
Verification checklist
Constraints and trade-offs
- Deferring analytics loses a small share of very short sessions.
- Facades duplicate some visual design for the placeholder.
- Worker sandboxes can break third parties that rely on synchronous DOM access.
- Iframes isolate work but add memory and communication overhead.
- Vendors update their scripts, so budgets need re-measuring after their releases, not only yours.
Frequently asked questions
How do I prove a third-party script is causing animation jank?
Collect long animation frame entries in the field and read the attributed source URL and invoker. That names the script and the callback responsible.
When should analytics and chat widgets load?
After the load event, during idle time, and after entrance animations finish. Chat and similar widgets are better loaded on interaction via a facade.
Can third-party code run off the main thread?
Sometimes. Worker-based sandboxes and iframes can isolate self-contained widgets, but code that must synchronously read or write your DOM generally cannot be moved.
What is a facade?
A lightweight placeholder that looks like the third-party widget and loads the real one only when the user interacts with it.
Related
- Main-Thread Scheduling & Long Animation Frames — the parent topic
- Idle-Until-Urgent Animation Setup — scheduling your own setup work
- Measuring Dropped Frames in Production — the field data behind the argument