Improve TBT with requestIdleCallback
December 15, 2024
As web developers, we're constantly striving to optimize our website's performance
to provide a seamless user experience. One crucial aspect of this is managing the
loading of third-party JavaScript files, which can significantly impact our
website's Total Blocking Time (TBT). In this blog post, we'll explore how leveraging
requestIdleCallback
can help us load 3rd party JavaScript files more efficiently
and improve our TBT scores.
What is requestIdleCallback?
requestIdleCallback
is a JavaScript API that enables developers to schedule tasks
to be executed when the browser's event loop is idle [5].
This API is particularly useful for non-essential tasks that don't require immediate
attention, such as loading third-party JavaScript files.
The down side here is that the browser support is still pretty low at the time of writing this. CanIUse is reporting only 77% coverage. However, there's a shim and polyfill available:
Why Use requestIdleCallback for Loading 3rd Party JavaScript?
Loading third-party JavaScript files can be a significant contributor to TBT,
as it blocks the main thread and prevents the browser from responding to user interactions.
By using requestIdleCallback
, we can defer the loading of these files to idle periods,
reducing the impact on our website's responsiveness.
defer vs requestIdleCallback
defer
is an attribute that can be added to script tags to indicate that the
script should be executed after the document has finished parsing. This means
that the script will be downloaded in parallel with the HTML parsing, but its
execution will be delayed until the HTML parsing is complete. This approach is
useful for scripts that don't need to be executed immediately, but still need to
be executed before the page is fully loaded.
On the other hand, requestIdleCallback
is a function that schedules a
callback to be executed during the browser's idle periods. This means that the
callback will be executed when the browser is not busy with other tasks, such as
rendering, parsing, or responding to user input. This approach is useful for tasks
that don't have a specific deadline and can be executed at any time, such as loading
non-essential resources or performing background tasks.
Lets build a Vue Componenet To Help
To start, lets build a single file component, to load our 3rd party javascript,
by using the requestIdleCallback
.
<script>
import { defineComponent, onMounted } from 'vue';
export default defineComponent({
name: 'ScriptLoader',
props: {
src: {
type: String,
required: true,
}
},
setup(props) {
const loadScript = () => {
const el = document.createElement('script');
const ignoredAttributes = ['onLoad'];
const { src } = props;
el.src = src;
// Map attrbutes to element, skip our ignore list
for (const [key, value] of Object.entries(props)) {
if (ignoredAttributes.includes(key)) {
continue;
}
const attr = key.toLowerCase();
el.setAttribute(attr, value);
};
document.body.appendChild(el);
}
const lazyLoadScript = () => {
if (document.readyState === 'complete') {
requestIdleCallback(() => loadScript());
} else {
const handler = () => {
requestIdleCallback(() => loadScript());
window.removeEventListener('load', handler);
}
window.addEventListener('load', handler);
}
}
onMounted(() => {
lazyLoadScript();
});
}
});
</script>
This utility component useScript
can now be used to load 3rd party scripts.
- It will take
src
prop that specifies which script to load - Implements lazy loading optimizations:
- If the page is already loaded (
readyState === 'complete'
), it uses requestrequestIdleCallback
to load the script when the browser is idle. - If the page is still loading, it waits for the
load
event before loading the script - Cleans up the event listerns to prevent memory leaks
- If the page is already loaded (
- This component is "renderless" and doesn't output any visible HTML.
Demo
The demo below simulates how different script loading strategies behave during page load. It simulates loading scripts from various CDNs while adding controlled delays to clearly illustrate the timing differences. Watch as regular scripts block execution (red), async scripts load independently (green), defer scripts wait for parsing (blue), and component-loaded scripts wait for browser idle time (orange).
Load Times Comparison
Test Controls
Improvements
Let's improve this a bit, and add a caching layer that will store a promise and call the onLoad
event in the case we have multiple components requesting the same script.
<script>
import { defineComponent, onMounted } from 'vue';
const CacheMap = new Map();
export default defineComponent({
name: 'ScriptLoader',
props: {
src: {
type: String,
required: true,
},
},
setup(props) {
const loadScript = () => {
const el = document.createElement('script');
const ignoredAttributes = ['onLoad'];
const { src, onLoad } = props;
if (CacheMap.has(src)) {
CacheMap.get(src)
.then(onLoad)
.catch(() => {
// If cached script failed, remove from cache and retry
CacheMap.delete(src);
loadScript();
});
return
}
const handleCacheLoad = new Promise((resolve, reject) => {
el.addEventListener('load', function(e) {
resolve();
if (onLoad) onLoad.call(this, e);
});
el.addEventListener('error', function(e) {
reject(e);
});
});
el.src = src;
CacheMap.set(src, handleCacheLoad);
// Map attrbutes to element, skip our ignore list
for (const [key, value] of Object.entries(props)) {
if (ignoredAttributes.includes(key)) {
continue;
}
const attr = key.toLowerCase();
el.setAttribute(attr, value);
};
document.body.appendChild(el);
}
const lazyLoadScript = () => {
if (document.readyState === 'complete') {
requestIdleCallback(() => loadScript());
} else {
const handler = () => {
requestIdleCallback(() => loadScript());
window.removeEventListener('load', handler);
}
window.addEventListener('load', handler);
}
}
onMounted(() => {
lazyLoadScript();
});
}
});
</script>
The CacheMap
is a global cache that stores Promises representing the loading state of external scripts. When a script is first requested, it creates a new Promise that resolves when the script loads successfully, stores this Promise in the CacheMap
using the script's URL as the key, and then appends the script to the document. When the same script URL is requested again by another component, instead of creating and loading a duplicate script tag, it retrieves the cached Promise from the CacheMap
and waits for it to resolve, ensuring that each external script is only loaded once no matter how many components request it.
Conculsion
By implementing this script loader component, we gain better control over how and when external scripts load in our Vue applications. This leads to improved performance, better user experience, and cleaner code organization. The component's simple yet powerful approach to script loading demonstrates how we can build reusable solutions for common web development challenges while maintaining performance best practices.
Remember, performance optimization is an ongoing process, and this component is just one tool in our optimization toolkit. Always measure and monitor your application's performance to ensure your optimizations are having the desired impact.