Why third-party scripts slow pages down

Third-party scripts are a common cause of performance problems. They add requests, block parsing, and compete with your own resources for bandwidth. The impact depends on how they are loaded, so regular audits and intentional loading strategies are worthwhile. This walkthrough applies three of those strategies—deferring scripts, lazy-loading below-the-fold content, and preconnecting to required origins—to a sample page that pulls in three third-party features: a video embed, a D3.js data visualization, and a social sharing widget.

Milica Mihajlija

Getting a performance baseline

Before changing anything, measure the current state. Remix the sample project, open it in fullscreen, and run a Lighthouse performance audit for mobile with simulated throttling and cleared storage.

Screenshot of the page with third-party resources highlighted.
Third-party resources in the sample app.

Exact results vary by machine, but expect a high First Contentful Paint (FCP). Lighthouse will also flag Eliminate render-blocking resources and Preconnect to required origins as opportunities, which is exactly where you will focus.

Screenshot of Lighthouse audit showing 2.4 second FCP and two opportunities: Eliminate render-blocking resources and Preconnect to required origins.

Defer non-critical JavaScript

Inside the page, the D3 library is loaded from d3js.org in the document <head>. That script is render-blocking: script.js, which uses D3 to render the line chart, sits right before the closing </body> tag because it must run after D3 is present.

The async and defer attributes both download scripts in the background without blocking the parser. With async, the script runs as soon as it finishes downloading. With defer, it waits until parsing is finished. Since the chart is not critical above-the-fold content, defer is the right choice.

Add defer where it matters

First, add the defer attribute to the D3 script tag in index.html.

<script src="https://d3js.org/d3.v3.min.js" defer></script>

Preserve script execution order

The timing is now wrong: a deferred D3 script will run long after script.js has already executed. Scripts marked defer run in document order, so add defer to script.js and move its tag next to the D3 script in the <head>. This also starts the download sooner while keeping execution correctly ordered.

<script src="https://d3js.org/d3.v3.min.js" defer></script>
<script src="./script.js" defer></script>

Lazy-load below-the-fold embeds

Anything below the fold is a candidate for lazy loading, and the YouTube iframe in the sample is a perfect example. Inspect the page in DevTools with the Network panel open and fast 3G throttling applied. You will see 28 requests and almost 1 MB transferred. Sorting by domain makes the source obvious: the iframe alone accounts for 14 requests across Google domains for scripts, stylesheets, images, and fonts—all unnecessary if the user never scrolls to play the video.

The Intersection Observer API provides a clean way to defer those requests until the iframe is about to enter the viewport.

Stop the initial load

An iframe only loads when its src attribute is present. Replace src with a data-src attribute holding the video URL. The iframe will then render nothing until JavaScript swaps the attributes.

<iframe width="560" height="315" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

Swap the source on scroll

Create lazy-load.js and include it via a script tag in the document head.

 <script src="https://web.dev/lazy-load.js" defer></script>

In that file, instantiate an IntersectionObserver with a callback function and call observe() on the iframe element. The callback receives an array of IntersectionObserverEntry objects, each with an isIntersecting boolean that becomes true when the target enters the viewport.

let observer = new IntersectionObserver(callback);
let observer = new IntersectionObserver(function(entries, observer) {
    entries.forEach(entry => {
      console.log(entry.target);
      console.log(entry.isIntersecting);
    });
  });

When isIntersecting is true, copy the value from data-src over to src to trigger the video load. Then call unobserve() on the observer so it stops watching the element.

let observer = new IntersectionObserver(function (entries, observer) {
  entries.forEach(entry => {
    console.log(entry.target);
    console.log(entry.isIntersecting);
  });
});
    if (entry.isIntersecting) {
      // do this when the element enters the viewport
      loadElement(entry.target);
      // stop watching
      observer.unobserve(entry.target);
    }
  });
});

function loadElement(element) {
  const src = element.getAttribute('data-src');
  element.src = src;
}

Measure the difference

Reload the page and recheck the Network panel. Requests drop to 14 and transferred bytes to about 260 KB. Scroll to the video position and additional requests fire as the iframe loads.

Preconnect to required origins

The earlier Lighthouse audit suggested saving roughly 400 ms by preconnecting to origins the page needs: staticxx.facebook.com and youtube.com. Since the YouTube iframe is now lazy-loaded, only staticxx.facebook.com, which serves the social sharing widget, qualifies for an early connection. One <link> tag in the <head> handles it.

  <link rel="preconnect" href="https://staticxx.facebook.com">

Final audit

Run the same Lighthouse audit as before against the optimized version to see the cumulative effect of all three techniques. The FCP will improve and the suggested opportunities will be gone.

Lighthouse audit showing 1 second FCP and the performance score of 99.