The fastest third-party script is the one that never runs

The fastest version of a third-party script is the one that never runs. I relearned this on a high-traffic news site where the server response was already good and the page still felt broken. The HTML arrived quickly, the article headline was right there in the markup, and yet the page just sat there, janky and unresponsive, while the main thread drowned in other people's JavaScript.
So this is a story about that site. The numbers that exposed the problem, the patterns that took it from embarrassing to fast, and the few places where doing the fast thing fought with what the business actually wanted. None of the techniques are clever. They're mostly about refusing to load things until you have a concrete reason to.
A fast server is not a fast page
The site ran on a modern stack with server rendering and a sensible cache in front of it. On a good day the document arrived in a few hundred milliseconds. By the only metric most backend dashboards care about, it was fine.
It was not fine. On a mid-range Android phone over throttled 4G, the page felt like wading through wet sand. Taps did nothing for a beat. Scrolling stuttered. The article title, which was sitting in the initial HTML, took the better part of five seconds to actually paint.
That gap between "the server is fast" and "the page feels fast" is where third-party JavaScript lives. Time to First Byte measures how quickly the server starts talking. It says nothing about what happens after the bytes land, when the browser has to parse, compile, and execute every script the page pulls in. The metric that captures that pain is Total Blocking Time, and in the lab it's a decent proxy for how unresponsive a page feels before it settles down. Out in the field, the same problem shows up as a bad Interaction to Next Paint.
The shape of the problem
The article template embedded the usual cast: Twitter/X tweets, Instagram posts, TikTok clips, a tag manager, and an analytics vendor. Each one ships its own SDK, and each SDK wants to execute the moment the page loads. The browser, being obedient, does exactly that.
When I opened the performance panel and recorded a typical article on a throttled profile, the picture was grim, and almost none of it was my code:
- Twitter's
widgets.jsalone cost about 816 ms of main-thread execution. - The combined social SDKs added well over 1.5 seconds of blocking work.
- The tag manager contributed a 241 ms task all on its own.
- The analytics vendor and a few smaller scripts piled on a few hundred milliseconds more.
The cruel part is that most of these embeds were below the fold. A reader on a phone was paying 800 ms to render a tweet they might never scroll to. The Largest Contentful Paint element was a plain <h1> already sitting in the server HTML, but it couldn't paint while the main thread was busy compiling and running embed code that had nothing to do with the headline.
Here's roughly what the load looked like before any of the work:
Here's the uncomfortable part: your performance budget isn't really yours. You're renting most of it out to vendors, and they spend without asking. Any serious fix has to start by taking that budget back.
Find the actual culprits before touching anything
It's tempting to start deleting scripts on instinct. Resist that. The first job is attribution, because the script you assume is the problem often isn't the one costing you the most.
Two tools did most of the work here. The performance panel's main-thread flame chart shows you exactly which script is parked on the CPU and for how long. Group the recording by long tasks, the ones over 50 ms, and the bad actors sort themselves to the top. The second tool is the attribution build of the web-vitals library, which in the field tells you not just that LCP was slow but why, breaking it into time to first byte, resource load delay, resource load time, and element render delay. On this site, the render-delay number was enormous while every other part was near zero, which pointed straight at main-thread contention rather than slow network or a heavy image.
You want numbers like these written down before and after every change. Otherwise you are optimizing by vibes, and vibes do not survive a stakeholder asking whether the work was worth it.
Stop loading what nobody asked for
The first real move is also the most effective one. Don't load an embed SDK until the embed is about to be seen. The browser already ships the tool for this: `IntersectionObserver`.
Instead of letting the platform <script> tags run at parse time, strip them out on the server and inject one small observer that loads each SDK only when its embed gets close to the viewport. Observe each embed individually, too, so a tweet near the top doesn't drag in the TikTok SDK at the bottom. A single shared loader that fires when any embed appears throws away most of the benefit.
type EmbedKind = "twitter" | "instagram" | "tiktok";
const SDK_URL: Record<EmbedKind, string> = {
twitter: "https://platform.twitter.com/widgets.js",
instagram: "https://www.instagram.com/embed.js",
tiktok: "https://www.tiktok.com/embed.js",
};
const loaded = new Set<EmbedKind>();
function loadSdk(kind: EmbedKind): void {
if (loaded.has(kind)) return; // never inject the same SDK twice
loaded.add(kind);
const s = document.createElement("script");
s.src = SDK_URL[kind];
s.async = true;
s.setAttribute("fetchpriority", "low"); // it can wait behind real content
document.body.appendChild(s);
}
const io = new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const kind = entry.target.getAttribute("data-embed") as EmbedKind;
loadSdk(kind);
observer.unobserve(entry.target); // load once, then stop watching
}
},
{ rootMargin: "300px" }, // start a little before the embed is on screen
);
document
.querySelectorAll<HTMLElement>("[data-embed]")
.forEach((el) => io.observe(el));Two knobs matter here. The rootMargin of 300px starts the load just before the embed scrolls into view, so the content is usually ready by the time the reader gets there instead of popping in late. And fetchpriority="low" tells the browser these requests can wait behind the fonts, CSS, and the LCP image. The Priority Hints API is about as cheap a win as you'll find. There's also a fallback worth keeping: if IntersectionObserver isn't available, load the SDKs on a stagger with small delays between them rather than all at once, so even the old-browser path doesn't jam the main thread.
That single change moved roughly 1.5 seconds of blocking work off the critical path on embed-heavy articles. But it still has a flaw, and the flaw is an assumption.
When lazy is still too eager: the facade
Lazy-loading on scroll assumes the reader wants the embed. Plenty don't. They came for the article, they scroll past the tweet, and they never needed 200 KB of widget code to do it. For the heaviest offenders I went further and replaced the live embed with a facade: cheap static HTML that looks like the embed, with the real SDK loading only on a click or a genuine interaction.
This is the import-on-interaction pattern, applied to other people's code instead of your own. Here is the lifecycle:
Getting this robust took three lessons, and I learned two of them the hard way.
The first lesson was about where the embeds actually come from. The CMS returned some embeds as <blockquote> markup and others as full <iframe> elements. My initial facade only handled blockquotes, so the iframe embeds happily fetched their payloads during HTML parsing and the facade bought nothing. The fix was to neutralize iframes on the server before they ever reached the browser, by moving src to data-src and only restoring it on activation.
function activateEmbed(container: HTMLElement): void {
const iframe = container.querySelector<HTMLIFrameElement>("iframe[data-src]");
if (!iframe) return;
const run = () => {
iframe.src = iframe.dataset.src!; // restore the real source
iframe.removeAttribute("data-src");
};
// Schedule during idle time so it never competes with a tap or scroll.
if ("requestIdleCallback" in window) {
requestIdleCallback(run, { timeout: 2000 });
} else {
setTimeout(run, 1);
}
}The second lesson cost me an afternoon. My first version generated the facade markup on the server and tried to wire up the click handler with an inline <script> injected into the article HTML. It silently did nothing. React does not execute <script> tags inserted through dangerouslySetInnerHTML, so the handler never attached and the embeds simply never loaded. The facades looked right and were completely dead. The fix was to stop smuggling behavior through HTML strings and move the whole thing into a real client component that runs on mount, finds the facades, and attaches its own listeners. If you take one practical thing from this piece, let it be that: behavior belongs in components, not in HTML you inject as a string.
The third lesson was about scheduling. When the real script finally loads, wrap the injection in `requestIdleCallback` so it slots into a quiet moment instead of fighting an interaction the user is making right now. Click-to-load on the single worst embed removed another 816 ms from the initial load, because Twitter's SDK simply never ran for most readers.
Where fast fought the business
Performance work does not happen in a vacuum, and this is the part most write-ups skip. Two of my "wins" caused real problems.
The first was the video player. I replaced the heavy embedded player with a static poster and a play button, which is the right call for load time. But the facade hid the video's title, and it turned out the title was doing real work: readers decided whether to play based on it. Plays dropped. The compromise was to keep the lightweight facade but auto-upgrade it to the full player a second after the reader's first interaction with the page, scheduled during idle time. No cost during the window that decides your Core Web Vitals, full functionality immediately after.
The second was the embeds staying dead until clicked. Editorial did not love that readers had to click every tweet to see it. So the facades grew the same behavior: listen for the first scroll, click, keydown, or touchstart, and a second later quietly activate the remaining embeds during idle time. Direct clicks still work instantly. The result keeps the main thread clear through the measurement window, then restores the "everything just loads" experience the moment the reader shows any sign of life.
The lesson I keep relearning: a performance fix that breaks the product is not a fix, it's a regression with good metrics. The interaction-triggered upgrade is the pattern that reconciles the two.
The scripts you cannot remove
Some things have to load: analytics, consent management, a tag manager the marketing team depends on. You can't delete them, but you can refuse to let them run during the window that matters.
For those, I stacked gates. Wait for the load event, then a short timer, then requestIdleCallback with a timeout so it still fires on a busy page. The tag manager went from a 241 ms task fighting the first paint to a quiet job that runs once the page is interactive and idle.
function loadWhenIdle(src: string, delayMs = 3000): void {
const inject = () => {
const s = document.createElement("script");
s.src = src;
s.async = true;
s.setAttribute("fetchpriority", "low");
document.body.appendChild(s);
};
// load event -> short delay -> idle. Each gate pushes the work later.
window.addEventListener("load", () => {
setTimeout(() => {
if ("requestIdleCallback" in window) {
requestIdleCallback(inject, { timeout: 8000 });
} else {
inject();
}
}, delayMs);
});
}The other trick for the scripts you're stuck with is caching. A vendor that serves its file with a one-day cache header is a vendor you re-download constantly, and you have no control over that header. Proxy the asset through your own domain, set a sane cache lifetime, and a repeated network cost becomes close to a one-time one. It also gives you a single throat to choke when the third party has an outage. In a framework like Next.js you can do the proxy with a rewrite and a cache header rule:
// next.config.ts
const nextConfig = {
async rewrites() {
return [
{ source: "/_proxy/analytics.js", destination: "https://vendor.example/sdk.js" },
];
},
async headers() {
return [
{
source: "/_proxy/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=604800, stale-while-revalidate=31536000",
},
],
},
];
},
};
export default nextConfig;A way to think about every third-party script
After enough of these, the individual tricks collapse into a single decision you can run on any script before you let it near a page.
Most scripts never make it past the second question, and that is the point. The default answer is "don't load," and every "yes" has to earn its place.
The numbers
Across the whole effort, the embed and script changes pulled well over two seconds of blocking work off the initial load on a heavy article. Total Blocking Time on the test page dropped into the green, comfortably under the 200 ms bar, and the headline started painting in roughly the time the server had always promised. The Lighthouse performance score moved from the high seventies into the low nineties, but the score was never the point. The point was that the page stopped fighting the reader.
What made it stick was measuring every change on a throttled mobile profile, not a developer laptop. The blocking time that ruins the experience is nearly invisible on fast hardware, which is exactly why it survives in production for so long.
Takeaways
- Treat every third-party script as guilty until proven necessary. The default should be "don't load," not "load and hope."
- Attribute before you act. Use the main-thread flame chart and web-vitals attribution to find the script that actually costs you, not the one you assume does.
- Defer by visibility with
IntersectionObserver, and defer by intent with facades. Most readers never trigger the expensive path, which is exactly what you want. - Reach for
fetchpriority="low"andrequestIdleCallbackto keep deferred work out of the critical window. One-line changes, outsized effect. - Neutralize on the server, not in the client. Once an
<iframe src>reaches the browser, you've already lost the request. And remember that React won't run a<script>you inject as a string. - Watch the product, not just the metrics. A fix that hides a video title or breaks an embed is a regression with a nice score. Interaction-triggered loading is how you get both.
- Measure on a throttled phone profile. The blocking time that ruins the experience is invisible on fast hardware.
The headline was in the HTML the whole time. The actual work was teaching the browser to paint it before doing everyone else's chores.

Autorius
Miracle Kalu
Senior Full Stack Engineer
Patiko tai, ką perskaitėte?
Svarstau vyresniojo inžinieriaus pozicijas ir techninio konsultavimo projektus. Susisiekime.
Susisiekti →Paskelbta 2026 m. birželio 14 d. · Skaitymo trukmė – 11 min.