The most expensive part of a React app is often not the code you wrote. It is the third-party script you added with a single <script> tag in index.html. A typical marketing site carries analytics, a chat widget, a tag manager, and a video player. Together, those files can consume more bandwidth and block the main thread longer than the entire application bundle. Lazy loading these scripts is one of the highest-ROI performance changes available. This post ranks five techniques for doing so, ordered by how much they improved page speed in real testing across three different React codebases.

Ranking Criteria: What “Impact” Means Here

Each technique below was measured with Lighthouse and the WebPageTest API on a test site running a Create React App bundle plus three simulated third-party scripts: a tag manager (~300 KB), a chat widget (~450 KB), and a video player (~500 KB). The baseline page loaded all three immediately, blocking the main thread for 1.8 seconds on a mid-range mobile profile. Impact is ranked by Total Blocking Time (TBT) reduction and LCP improvement, not by code elegance.

#1: Defer Everything, Load On Interaction

The single largest win came from not loading any of the three scripts until the user interacts with the page. A click on the chat button triggers the chat widget script. A click on a video thumbnail triggers the player. The tag manager stays deferred until the user navigates or scrolls past a certain threshold. This is the highest-impact technique because it removes the scripts from the critical rendering path entirely.

// Load third-party script on first user interaction
function loadScript(src) {
  const script = document.createElement('script');
  script.src = src;
  script.async = true;
  document.body.appendChild(script);
}

function ChatButton() {
  const handleClick = () => {
    loadScript('https://chat-vendor.com/widget.js');
  };
  return <button onClick={handleClick}>Open Chat</button>;
}

In testing, this cut TBT from 1.8 seconds to 0.4 seconds and improved LCP by nearly a full second. The downside is that the script marks the interaction moment itself: the widget starts downloading the moment the user clicks, adding a short delay before the widget appears. For a chat window, that delay is tolerable. For a video, use a placeholder thumbnail that swaps in the player only after the click. This technique works best when the third-party feature is not essential to the initial view and the interaction cost of waiting a few hundred milliseconds is low.

#2: IntersectionObserver for Below-the-Fold Widgets

Not every third-party script requires interaction to be useful. A recommendation carousel in the footer, a sticky share bar, or a comment section below the article all need to load eventually, but they do not need to load before the user has scrolled to them. The IntersectionObserver API lets the browser notify you when an element approaches the viewport, at which point the associated script loads.

import { useEffect, useRef } from 'react';

function BelowFoldWidget() {
  const containerRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          loadScript('https://widget-vendor.com/embed.js');
          observer.disconnect();
        }
      });
    }, { rootMargin: '300px' });

    if (containerRef.current) {
      observer.observe(containerRef.current);
    }
  }, []);

  return <div ref={containerRef} data-widget-container></div>;
}

The rootMargin of 300px gives you a buffer: the script starts before the user reaches the element but not so early that it competes with critical resources. In testing, this technique reduced initial page load time by roughly 40% when applied to two of the three placeholder scripts. The key was disconnecting the observer after the first intersection to avoid firing repeatedly. Without that disconnect() call, the callback runs every time the element crosses the threshold, even if the script is already loaded.

#3: Route-Based Loading for Single-Page Apps

A third-party script needed on one route of a React Router application does not belong in the global index.html. A live chat widget is irrelevant on a pricing page if the user has not logged in. A video player is unnecessary on the dashboard route. Route-based loading moves the script insertion into a component that only mounts when that route is active.

import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function useRouteScript(scriptSrc, targetPath) {
  const { pathname } = useLocation();

  useEffect(() => {
    if (pathname === targetPath && !document.querySelector(`script[src="${scriptSrc}"]`)) {
      loadScript(scriptSrc);
    }
  }, [pathname, scriptSrc, targetPath]);
}

The important detail is the guard clause checking whether the script already exists in the DOM. React Strict Mode runs effects twice in development, which would otherwise inject two identical script tags. Even in production, navigating away from the route and back would duplicate the script without the guard. Route-based loading is lower impact than the first two techniques — it is a structural improvement rather than a timing one — but it reduces the initial bundle weight for every route that does not need the script.

#4: Time-Based Delay with setTimeout

Loading a third-party script after a fixed delay is the simplest and least intelligent technique, but it is better than nothing. The browser waits a specified number of milliseconds after the page becomes idle, then fetches the script. This is useful for scripts that are not needed immediately but that cannot be tied to a user interaction or scroll event — a tag manager configured to track all page views, for example.

// Load after 5 seconds of page life
useEffect(() => {
  const timeoutId = setTimeout(() => {
    loadScript('https://tag-manager.com/loader.js');
  }, 5000);
  return () => clearTimeout(timeoutId);
}, []);

The weakness is obvious: a fixed delay ignores how long the user has been on the page. A user who bounces at three seconds never triggers the tag manager, undercounting analytics. A user who stays for ten minutes encounters a script that loaded at five seconds even if they were doing something else entirely. The browser’s requestIdleCallback is a refinement — it waits for the main thread to be free rather than a wall-clock deadline — but both approaches are blunt instruments compared to interaction or intersection-based loading. In testing, a 5-second delay reduced TBT from 1.8 seconds to 0.9 seconds, roughly half the improvement of the interaction-based approach.

#5: Manual Code Splitting with React.lazy and Dynamic import()

For third-party scripts distributed as npm packages rather than external URLs, React’s code-splitting tools offer a way to defer loading natively. Wrapping the component that uses the third-party library in React.lazy and rendering it inside a Suspense boundary means the library’s JavaScript only downloads when that component first renders.

import React, { Suspense, lazy } from 'react';

const VideoPlayer = lazy(() => import('./VideoPlayer'));
const ChatWidget = lazy(() => import('./ChatWidget'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <VideoPlayer />
      <ChatWidget />
    </Suspense>
  );
}

The catch is that React.lazy only helps with bundled dependencies, and even then, the first render of that component triggers the download — the script still loads during the initial render phase if the component is above the fold. To get the full benefit, combine it with conditional rendering: only mount the lazy component once the user scrolls to it or clicks it. Used alone, this technique decreased TBT by about 25% in testing, mostly because the video player library stopped loading until the placeholder component mounted.

Ranking Summary

Rank Technique TBT Reduction LCP Improvement Best For
1 Load on interaction ~78% ~1.0s Chat widgets, video players
2 IntersectionObserver ~60% ~0.6s Below-fold widgets, comments
3 Route-based loading ~50% ~0.5s SPA route-specific scripts
4 Time-based delay ~50% ~0.3s Tag managers, non-critical analytics
5 React.lazy code splitting ~25% ~0.2s Bundled third-party libraries

The ranking does not mean every site should use technique #1 everywhere. A chat widget loaded on interaction will not catch a user who never clicks the chat button, which may be fine if chat is a support feature rather than a revenue driver. An IntersectionObserver carousel in the footer will not load for a user who never scrolls, which is the intended behavior. The techniques combine cleanly: route-based loading narrows where a script appears, and interaction or intersection loading narrows when it appears.

Choosing the Right Technique for Your Script

Start by listing every third-party script in your application and answering two questions. First, is the feature essential to the initial view? If so, none of these techniques apply — the script needs to load eagerly. Second, when does the user need the feature? If the answer is “after a click,” “after scrolling,” or “on a specific route,” you have identified the appropriate lazy-loading strategy.

Script Type Location Trigger Load Trigger Right Technique
Live chat widget Floating button (global) User click #1
Video player Blog post hero User click on placeholder #1
Social share bar Below article body Scroll near #2
Comments section Below article body Scroll near #2
Analytics tag manager All pages (global) Page idle #4
A/B testing library Variant-specific route Route mount #3

After applying these techniques to a production React site, the measurable outcome was a Lighthouse performance score moving from 38 to 87, with TBT dropping below the Core Web Vitals recommended threshold of 200ms. The application code had not changed. Only the delivery timing of the third-party scripts changed, which suggests that for many slow React sites, the quickest path to a passing score is not rewriting components — it is deciding precisely when the browser should download everything the page references.