Prior to WordPress version 6.3, us developers had to abuse the script_loader_tag filter or wp_print_scripts action to hack together the script’s HTML element to add the async
and/or defer
attributes. Now, with version 6.3 of WordPress core that was released on August 8, 2023, we can do it when we enqueue/register the script!
Before
wp_register_script(
'foo', // Handle
'/path/to/foo.js', // Source
array(), // Dependencies
'1.0.0', // Version
true // Place in footer
);
After
wp_register_script(
'foo', // Handle
'/path/to/foo.js', // Source
array(), // Dependencies
'1.0.0', // Version
array(
'in_footer' => true, // Place in footer
'strategy' => 'defer' // Defer loading
);
So, that’s great if you’re using only WordPress themes and plugins you’ve made since you can make that update yourself. But what if you’re using themes and/or plugins made by somebody else and it hasn’t been updated? Well, here’s some code snippets you can use to force all of your scripts to use the defer or async strategies without chopping up the tag’s HTML!
Want to skip all my explanations and go straight to the code you can copy/paste? No problem. You’re probably a pro. Respect.
Optimize All Scripts
I started with a function called au_optimize_scripts
and it’s an action hooked onto wp_print_scripts
and wp_print_footer_scripts
. I’ve set the priority so high for the wp_print_scripts
action so it runs absolutely last, after every other function hooked to this action and for the wp_print_footer_scripts
action, I’ve set it low in the hopes that it’ll run first. I chose to run it on both hooks because some scripts get enqueued after the head scripts are printed, like scripts only added if a certain widget or block exists on the page.
/**
* Optimize Scripts
*
* Loops through the scripts enqueued on the frontend and optionally sets their strategy to defer or async.
*
* @return void
*/
function au_optimize_scripts()
{
$wp_scripts = wp_scripts();
foreach ($wp_scripts->queue as $handle) {
au_optimize_script($handle, au_array_has_key($handle, $wp_scripts->registered, null), $wp_scripts);
}
}
add_action('wp_print_scripts', 'au_optimize_scripts', PHP_INT_MAX, 0);
add_action('wp_print_footer_scripts', 'au_optimize_scripts', 0, 0);
In this function, I grab a list of all the scripts that are currently enqueued for the page and I loop through them, calling a function to optimize them individually. Inside the loop of this function, I also use my helper function au_array_has_key
. It is similar to the PHP array_key_exists function in that it checks if a key exists in an array, but it also returns the value if it’s found or returns a default value if it is not.
/**
* Array Key Exists and Has Value
*
* @param string $key The key to search for in the array.
* @param array $array The array to search.
* @param mixed $default The default value to return if not found or is empty. Default is an empty string.
*
* @return mixed|null The value of the key found in the array if it exists or the value of `$default` if not found or is empty.
*/
function au_array_has_key($key = '', $array = array(), $default = '')
{
//Check if this key exists in the array
if (is_string($key) && is_array($array) && count($array) && array_key_exists($key, $array)) {
if (is_array($array[$key])) {
return count($array[$key]) ? $array[$key] : $default; // If this is an array, only return it if has any values
} elseif (is_bool($array[$key]) || is_numeric($array[$key]) || $array[$key]) {
return $array[$key]; //Always return if it's a boolean, number, or other truthy value
}
}
return $default;
}
So the script that optimizes each function individually is called au_optimize_script
and it takes three arguments. The first one, the $handle
, is required but the second two, $data
and $wp_scripts
, are optional. This first snippet is the nuclear option: defer everything that doesn’t have a strategy set. Basically, if a script is enqueued with no strategy set, it is set to be deferred. So anything already set to async or defer is skipped.
It also loops through the script’s listed dependencies to defer them as well.
/**
* Optimize Single Script
*
* @param string $handle Name of the script
* @param _WP_Dependency|null $data Optional. The enqueue/registration data of the script.
* @param WP_Scripts|null Optional. Current instance of WP_Scripts. Default is null to trigger lookup.
*
* @return void
*/
function au_optimize_script($handle, $data = null, $wp_scripts = null)
{
if (!($has_data = !is_null($data)) || !au_array_has_key('strategy', $data->extra)) {
wp_script_add_data($handle, 'strategy', 'defer'); // Set the script's strategy to defer
if ($has_data) {
// Update dependencies since changing to defer load in footer now
if (count($data->deps)) {
foreach ($data->deps as $handle_dep) {
if (is_null($wp_scripts)) {
$wp_scripts = wp_scripts();
}
au_optimize_script($handle_dep, au_array_has_key($handle_dep, $wp_scripts->registered, null), $wp_scripts);
}
}
} elseif ($handle == 'jquery' && !in_array('jquery-core', $wp_scripts->queue)) {
// Also do it for jQuery core since it's not enqueued the same as jQuery
au_optimize_script('jquery-core', au_array_has_key('jquery-core', $wp_scripts->registered, null), $wp_scripts);
}
}
}
Additionally, you can add this snippet below the wp_script_add_data()
to force it to the footer if it loads in the header.
wp_script_add_data($handle, 'group', 1); // Move script to footer
Optimize Some Scripts
Sometimes, deferring every single script that loads on your website isn’t what you need. Sometimes you need a script to run when it’s called for it to function properly. Let’s modify our script to allow exclusions. To start, add these four (4) lines above the function. Could even be in your wp-config.php
if you’d like.
define('AU_EXCLUDED_SCRIPT_HANDLES', 'neve-script');
define('AU_EXCLUDED_SCRIPT_HANDLE_PREFIX', 'woocommerce-');
define('AU_EXCLUDED_SCRIPT_URLS', '');
define('AU_EXCLUDED_SCRIPT_URL_PARTS', 'google.com/recaptcha/api.js');
Here, we’re defining comma-separated lists for these functions. The AU_EXCLUDED_SCRIPT_HANDLES
constant is a comma-separated list of script handles that we want to exclude. You can identify the script’s handle by it’s ID in the source code. For example, the Neve WordPress theme loads this script:
<script type='text/javascript' src='http://aurisecreative.com/wp-content/themes/neve/assets/js/build/modern/frontend.js' id='neve-script-js'></script>
And I know that the -js
part of the ID is generated, so the handle for this script is likely to be neve-script
.
The AU_EXCLUDED_SCRIPT_HANDLE_PREFIX
is similar except that it’s a comma-separated list of handle prefixes. If you have the WooCommerce plugin in addition to the Neve theme, you might see this script:
<script type='text/javascript' src='https://aurisecreative.com/wp-content/themes/neve/assets/js/build/modern/shop.js' id='neve-shop-script-js'></script>
Instead of using neve-script,neve-shop
in the first constant, you can simply put neve-
in this prefix constant and it’ll match both.
The AU_EXCLUDED_SCRIPT_URLS
and AU_EXCLUDED_SCRIPT_URL_PARTS
work the same way except matching the src
attribute of the script tag.
Now let’s do something with these constants now that they’ve been set! Plop this snippet inside the first if-statement of the au_optimize_script
function:
/**
* Identify Excluded
*/
if (defined('AU_EXCLUDED_SCRIPT_HANDLES') && !empty(constant('AU_EXCLUDED_SCRIPT_HANDLES')) && in_array($handle, array_unique(array_filter(explode(',', constant('AU_EXCLUDED_SCRIPT_HANDLES')))))) {
return; // Exclude specified handles
}
if (defined('AU_EXCLUDED_SCRIPT_HANDLE_PREFIX') && !empty(constant('AU_EXCLUDED_SCRIPT_HANDLE_PREFIX'))) {
$excluded_handles = array_unique(array_filter(explode(',', constant('AU_EXCLUDED_SCRIPT_HANDLE_PREFIX'))));
foreach ($excluded_handles as $excluded_handle_part) {
if (strpos($handle, $excluded_handle_part) === 0) {
return; // Exclude those that include this part of a handle
}
}
}
if (!$has_data) {
if (defined('AU_EXCLUDED_SCRIPT_URLS') && !empty(constant('AU_EXCLUDED_SCRIPT_URLS')) && in_array($data->src, array_unique(array_filter(explode(',', constant('AU_EXCLUDED_SCRIPT_URLS')))))) {
return; // Exclude specified URLs
}
if (defined('AU_EXCLUDED_SCRIPT_URL_PARTS') && !empty(AU_EXCLUDED_SCRIPT_URL_PARTS)) {
$excluded_urls = array_unique(array_filter(explode(',', AU_EXCLUDED_SCRIPT_URL_PARTS)));
foreach ($excluded_urls as $excluded_url_part) {
if (strpos($data->src, $excluded_url_part) !== false) {
return; // Exclude those that include this part in their URL
}
}
}
}
This checks for those functions’ existence and will abort the function early if the handle it’s currently checking within the loop matches any of the parameters you set in those constant variables.
Set Some Scripts to Load Asynchronously
So we’ve got scripts that theme and plugin developers have updated being skipped, we can exclude scripts that we don’t want to be deferred, but now let’s identify scripts we want to load asynchronously by setting the async
strategy. We’re going to identify four (4) more constant variables like this:
define('AU_ASYNC_SCRIPT_HANDLES', 'ewww-webp-load-script');
define('AU_ASYNC_SCRIPT_HANDLE_PREFIX', 'eio-lazy-load-');
define('AU_ASYNC_SCRIPT_URLS', 'ewww-webp-check-script');
define('AU_ASYNC_SCRIPT_URL_PARTS', '');
Like our previous constants, we’re doing handles, handle prefixes, URLs, and URL parts for matching. So now below the exclusion logic, let’s add our async logic like so:
/**
* Async
*/
$async = defined('AU_ASYNC_SCRIPT_HANDLES') && !empty(constant('AU_ASYNC_SCRIPT_HANDLES')) && in_array($handle, array_unique(array_filter(explode(',', constant('AU_ASYNC_SCRIPT_HANDLES')))));
$async = $async ? $async : ($has_data && defined('AU_ASYNC_SCRIPT_URLS') && !empty(constant('AU_ASYNC_SCRIPT_URLS')) && in_array($data->src, array_unique(array_filter(explode(',', constant('AU_ASYNC_SCRIPT_URLS'))))));
if (!$async && defined('AU_ASYNC_SCRIPT_HANDLE_PREFIX') && !empty(AU_ASYNC_SCRIPT_HANDLE_PREFIX)) {
$async_prefixes = array_unique(array_filter(explode(',', AU_ASYNC_SCRIPT_HANDLE_PREFIX)));
foreach ($async_prefixes as $async_prefix) {
if (strpos($handle, $async_prefix) === 0) {
$async = true;
break; // Break early from for-loop if found
}
}
}
if (!$async && $has_data && defined('AU_ASYNC_SCRIPT_URL_PARTS') && !empty(constant('AU_ASYNC_SCRIPT_URL_PARTS'))) {
$async_urls = array_unique(array_filter(explode(',', constant('AU_ASYNC_SCRIPT_URL_PARTS'))));
foreach ($async_urls as $async_url_part) {
if (strpos($data->src, $async_url_part) !== false) {
$async = true;
break; // Break early from for-loop if found
}
}
}
if ($async) {
wp_script_add_data($handle, 'strategy', 'async');
} else {
/**
* Defer Everything Else
*/
}
In essence, we establish the $async
variable and we’re doing similar checks for all the constants. The final bit is a new if-else statement that checks that variable, and if it was true after all the checking of the constants, then we add the async
attribute to the script. Otherwise, continue on with the defer
attribute!
Just the Code
Okay, I imagine some people just want code to copy and paste. Here you go. Throw this bit in your active theme’s functions.php
file:
/**
* Define Scripts for Exclusion and Async
*/
define('AU_EXCLUDED_SCRIPT_HANDLES', '');
define('AU_EXCLUDED_SCRIPT_HANDLE_PREFIX', '');
define('AU_EXCLUDED_SCRIPT_URL_PARTS', '');
define('AU_ASYNC_SCRIPT_HANDLES', '');
define('AU_ASYNC_SCRIPT_HANDLE_PREFIX', '');
define('AU_ASYNC_SCRIPT_URLS', '');
define('AU_ASYNC_SCRIPT_URL_PARTS', '');
/**
* Optimize Single Script
*
* If script does not have a strategy, it is set to be deferred unless it is defined as excluded or async in constant variables.
*
* @param string $handle Name of the script
* @param _WP_Dependency|null $data Optional. The enqueue/registration data of the script.
* @param WP_Scripts|null Optional. Current instance of WP_Scripts. Default is null to trigger lookup.
*
* @return void
*/
function au_optimize_script($handle, $data = null, $wp_scripts = null)
{
if (!($has_data = !is_null($data)) || !au_array_has_key('strategy', $data->extra)) {
/**
* Identify Excluded
*/
if (defined('AU_EXCLUDED_SCRIPT_HANDLES') && !empty(constant('AU_EXCLUDED_SCRIPT_HANDLES')) && in_array($handle, array_unique(array_filter(explode(',', constant('AU_EXCLUDED_SCRIPT_HANDLES')))))) {
return; // Exclude specified handles
}
if (defined('AU_EXCLUDED_SCRIPT_HANDLE_PREFIX') && !empty(constant('AU_EXCLUDED_SCRIPT_HANDLE_PREFIX'))) {
$excluded_handles = array_unique(array_filter(explode(',', constant('AU_EXCLUDED_SCRIPT_HANDLE_PREFIX'))));
foreach ($excluded_handles as $excluded_handle_part) {
if (strpos($handle, $excluded_handle_part) === 0) {
return; // Exclude those that include this part of a handle
}
}
}
if ($has_data) {
if (defined('AU_EXCLUDED_SCRIPT_URLS') && !empty(constant('AU_EXCLUDED_SCRIPT_URLS')) && in_array($data->src, array_unique(array_filter(explode(',', constant('AU_EXCLUDED_SCRIPT_URLS')))))) {
return; // Exclude specified URLs
}
if (defined('AU_EXCLUDED_SCRIPT_URL_PARTS') && !empty(constant('AU_EXCLUDED_SCRIPT_URL_PARTS'))) {
$excluded_urls = array_unique(array_filter(explode(',', constant('AU_EXCLUDED_SCRIPT_URL_PARTS'))));
foreach ($excluded_urls as $excluded_url_part) {
if (strpos($data->src, $excluded_url_part) !== false) {
return; // Exclude those that include this part in their URL
}
}
}
}
/**
* Async
*/
$async = defined('AU_ASYNC_SCRIPT_HANDLES') && !empty(constant('AU_ASYNC_SCRIPT_HANDLES')) && in_array($handle, array_unique(array_filter(explode(',', constant('AU_ASYNC_SCRIPT_HANDLES')))));
$async = $async ? $async : ($has_data && defined('AU_ASYNC_SCRIPT_URLS') && !empty(constant('AU_ASYNC_SCRIPT_URLS')) && in_array($data->src, array_unique(array_filter(explode(',', constant('AU_ASYNC_SCRIPT_URLS'))))));
if (!$async && defined('AU_ASYNC_SCRIPT_HANDLE_PREFIX') && !empty(AU_ASYNC_SCRIPT_HANDLE_PREFIX)) {
$async_prefixes = array_unique(array_filter(explode(',', AU_ASYNC_SCRIPT_HANDLE_PREFIX)));
foreach ($async_prefixes as $async_prefix) {
if (strpos($handle, $async_prefix) === 0) {
$async = true;
break; // Break early from for-loop if found
}
}
}
if (!$async && $has_data && defined('AU_ASYNC_SCRIPT_URL_PARTS') && !empty(constant('AU_ASYNC_SCRIPT_URL_PARTS'))) {
$async_urls = array_unique(array_filter(explode(',', constant('AU_ASYNC_SCRIPT_URL_PARTS'))));
foreach ($async_urls as $async_url_part) {
if (strpos($data->src, $async_url_part) !== false) {
$async = true;
break; // Break early from for-loop if found
}
}
}
if ($async) {
wp_script_add_data($handle, 'strategy', 'async');
} else {
/**
* Defer Everything Else
*/
wp_script_add_data($handle, 'strategy', 'defer'); // Set the script's strategy to defer
if ($has_data) {
// Update dependencies since changing to defer load in footer now
if (count($data->deps)) {
foreach ($data->deps as $handle_dep) {
if (is_null($wp_scripts)) {
$wp_scripts = wp_scripts();
}
au_optimize_script($handle_dep, au_array_has_key($handle_dep, $wp_scripts->registered, null), $wp_scripts);
}
}
} elseif ($handle == 'jquery' && !in_array('jquery-core', $wp_scripts->queue)) {
// Also do it for jQuery core since it's not enqueued the same as jQuery
au_optimize_script('jquery-core', au_array_has_key('jquery-core', $wp_scripts->registered, null), $wp_scripts);
}
}
}
}
/**
* Optimize Scripts
*
* Loops through the scripts enqueued on the frontend and optionally sets their strategy to defer or async.
*
* @return void
*/
function au_optimize_scripts()
{
$wp_scripts = wp_scripts();
foreach ($wp_scripts->queue as $handle) {
au_optimize_script($handle, au_array_has_key($handle, $wp_scripts->registered, null), $wp_scripts);
}
}
add_action('wp_enqueue_scripts', 'au_optimize_scripts', PHP_INT_MAX, 0);