Defer Scripts Until They Are Needed
Load JavaScript on demand using IntersectionObserver and user interaction triggers

Defer scripts until they are needed
The median mobile page ships 251 KB of unused JavaScript according to the 2025 Web Almanac. That is JavaScript the browser downloads, parses, and compiles before a visitor ever needs it. Forms that nobody has clicked on. Chat widgets that nobody has opened. Map integrations that sit below the fold. All of it competing for bandwidth and CPU time during the most critical phase of page load.
The most effective way to deal with this is to not load scripts until they are actually needed. This is different from using the async or defer attribute on a script tag. Those attributes still download the script during page load; they just change when it executes. On-demand loading does not download the script at all until a trigger fires.
Last reviewed by Arjen Karel on March 2026
We have been doing this with images for a long time. It is called lazy loading. With lazy loading, a below-the-fold image is loaded right before it scrolls into view. The browser can spend its resources on downloading, parsing, and painting things that are actually needed. The same principle applies to JavaScript, and it will fix the Lighthouse warning "reduce unused JavaScript" and improve responsivity metrics like Interaction to Next Paint (INP).
Unfortunately it is not as simple as adding loading="lazy" to an image, but with a small helper function and a trigger we can make it work.
The script injection helper
To add scripts to the page after page load we need a small function that creates a script element and appends it to the document head.
function injectScript(scriptUrl, callback) {
const script = document.createElement('script');
script.src = scriptUrl;
if (typeof callback === 'function') {
script.onload = callback;
}
document.head.appendChild(script);
} The scriptUrl parameter is the URL of the script to load. The optional callback function runs after the script finishes loading. This is important for scripts that need initialization, like calling initMap() after loading the Google Maps API.
Triggering the load on demand
With the injection helper in place, we need a trigger. There are two reliable methods: loading when an element scrolls into view, and loading when the user interacts with an element.
IntersectionObserver: load when visible
The IntersectionObserver fires when an element enters the viewport. This is the right trigger for scripts tied to a specific section of the page: a map container, a comments section, or an embedded widget below the fold.
function injectScriptOnIntersection(scriptUrl, elementSelector, callback) {
const element = document.querySelector(elementSelector);
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
injectScript(scriptUrl, callback);
obs.unobserve(entry.target);
}
});
});
observer.observe(element);
} The function takes the script URL, a CSS selector for the element that should trigger the load, and an optional callback for initialization. When the element scrolls into view, the script is injected and the observer disconnects.
// Load the Google Maps API when the map container scrolls into view
injectScriptOnIntersection(
'https://maps.googleapis.com/maps/api/js?key=YOUR_KEY',
'#map-container',
() => initMap()
); IntersectionObserver is supported in all modern browsers (95.76% global coverage according to Can I Use). No polyfill is needed.
On interaction: load when the user engages
The most effective method is to load a script only when the visitor actually interacts with the element that needs it. A chat widget does not need to load until someone clicks the chat button. A form validation library does not need to load until the user focuses on a form field.
function injectScriptOnInteraction(scriptUrl, elementSelector, eventTypes, callback) {
const element = document.querySelector(elementSelector);
const handler = () => {
eventTypes.forEach(type => element.removeEventListener(type, handler));
injectScript(scriptUrl, callback);
};
eventTypes.forEach(type => {
element.addEventListener(type, handler);
});
} This function listens for the specified events on the target element. On the first event, it removes all listeners and injects the script. The advantage: if the visitor never interacts with the element, the script never loads at all.
// Load chat widget script when the chat button is clicked or hovered
injectScriptOnInteraction(
'chat-widget.js',
'#chat-button',
['click', 'mouseover', 'touchstart'],
() => initChat()
); Real-world impact
This pattern works for any script that is not needed during initial page load. Some common use cases:
- Chat widgets: A typical chat widget loads 200 to 400 KB of JavaScript. When Postmark deferred their Intercom widget to load on click instead of eagerly, their Time to Interactive dropped from 7.7 seconds to 3.7 seconds.
- Video embeds: A YouTube embed loads over 1 MB of data. Show a thumbnail with a play button and load the embed on click.
- Map integrations: Google Maps loads hundreds of kilobytes of JavaScript. Use IntersectionObserver to load it when the map container scrolls into view.
- Analytics and tracking: Analytics scripts can wait until after the first user interaction. Nobody was ever disappointed that their heatmap tool started recording 3 seconds after page load.
- Form libraries: Validation libraries, date pickers, and rich text editors can load when the user focuses on the form.
When not to defer
Not every script should be deferred. If a script is responsible for rendering above-the-fold content, deferring it will make your Largest Contentful Paint worse, not better. Scripts that initialize your header navigation, render your hero section, or set up critical A/B test variants need to run early.
The rule is simple: if the visitor will see or interact with what the script produces within the first viewport, load it normally. If the script powers something below the fold or behind a user action, defer it using one of the patterns above.
Tip: For a complete overview of all JavaScript loading strategies, see 16 methods to defer or schedule JavaScript.
Measuring the improvement
The 2025 Web Almanac reports a median mobile Total Blocking Time of 1,916ms, up 58% from 2024. Much of that blocking comes from JavaScript that did not need to run during page load. By deferring non-critical scripts, you remove them from the critical path entirely.
After implementing on-demand loading, verify the improvement with Real User Monitoring. Check your INP scores and Total Blocking Time in field data, not just Lighthouse. Lab tests run on fast machines with empty caches. Your visitors are on mobile networks with 15 browser tabs open. That is where the difference shows.
Performance degrades unless you guard it.
I do not just fix the metrics. I set up the monitoring, the budgets, and the processes so your team keeps them green after I leave.
Start the Engagement
