Skip to content
Home » Blog » Computer Science » Web Development » WordPress » How to Force JavaScript to use Async/Defer in WordPress 6.3

How to Force JavaScript to use Async/Defer in WordPress 6.3

4 minute read

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);

Nobody has commented on this yet, be the first!

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

The reCAPTCHA verification period has expired. Please reload the page.