Registering scripts with `async` and `defer` attributes in WordPress 6.3

WordPress 6.3 introduces support for registering scripts with async and defer attributes as part of an enhancementenhancement Enhancements are simple improvements to WordPress, such as the addition of a hook, a new feature, or an improvement to an existing feature. to coreCore Core is the set of software required to run WordPress. The Core Development Team builds WordPress.’s existing Scripts APIAPI An API or Application Programming Interface is a software intermediary that allows programs to interact with each other and share data in limited, clearly defined ways.. This addresses a long-standing Trac ticket, and adds the ability to define a loading strategy for scripts. Supported strategies are as follows:

  • Blocking (default, this strategy is not supplied)
  • Deferred (by supplying a defer loading strategy)
  • Asynchronous (by supplying an async loading strategy)

This enhancement was originally proposed in December 2022.

Why is this enhancement useful?

Adding defer or async to script tags enables script loading without “blocking” the rest of the page load, resulting in more performant sites via improved Largest Contentful Paint (LCP) performance. This leads to a better user experience. This has been common practice in web engineering for over a decade, yet to date there have been no core methods or a means of achieving this when registering/enqueuing scripts using core WordPress APIs.

Prior to this enhancement, developers have had to resort to less than ideal alternatives such as directly filtering the tags at the point of output (using the script_loader_tag filterFilter Filters are one of the two types of Hooks https://codex.wordpress.org/Plugin_API/Hooks. They provide a way for functions to modify data of other functions. They are the counterpart to Actions. Unlike Actions, filters are meant to work in an isolated manner, and should never have side effects such as affecting global variables and output., or worse the clean_url filter), or handling the tagtag A directory in Subversion. WordPress uses tags to store a single snapshot of a version (3.6, 3.6.1, etc.), the common convention of tags in version control systems. (Not to be confused with post tags.) output directly using wp_print_script_tag and the wp_script_attributes filter. Although fairly common practice (as it was the only means available prior), it is considered “hacky” as it does not take into account the dependency tree, or inline scripts for that matter, which can lead to interoperability issues or bugs with other scripts.

The difference between deferred (via the defer script attribute) and asynchronous (via the async script attribute) scripts is as follows:

  • Deferred scripts
    Scripts marked for deferred execution — via the defer script attribute — are only executed once the DOM tree has fully loaded (but before the DOMContentLoaded and window load events). Deferred scripts are executed in the same order they were printed/added in the DOM, unlike asynchronous scripts.
  • Asynchronous scripts
    Scripts marked for asynchronous execution — via the async script attribute — are executed as soon as they are loaded by the browser. Asynchronous scripts do not have a guaranteed execution order, as script B (although added to the DOM after script A) may execute first given that it may complete loading prior to script A. Such scripts may execute either before the DOM has been fully constructed or after the DOMContentLoaded event.

Summary of the changes

At a high level, the changes can be summarized as follows:

  • WordPress now adds support for specifying a script loading strategy via the wp_register_script() and wp_enqueue_script() functions.
  • These functions have new function signatures, with the prior $in_footer boolean parameter being overloaded to accept a new $args array parameter in order to facilitate an easy entry point for specifying a loading strategy for a script, while still retaining full backward compatibility for $in_footer implementations; this retains the means of specifying whether a script should be printed in the footer or not via a key within the new $args parameter. Note that the strategy can also be specified in a backwards-compatible way via wp_script_add_data().

Various additions and enhancements to the WP_Scripts class were made to facilitate the necessary business logic that prepares and outputs a script’s loading strategy.

Example 1: Specifying a loading strategy for a script

A loading strategy may be assigned via the wp_register_script() and wp_enqueue_script() functions by passing a strategy key value pair to the new/overloaded $args parameter. 

The following example showcases a new script with handle 'foo' being registered as a deferred script:

wp_register_script( 
	'foo', 
	'/path/to/foo.js', 
	array(), 
	'1.0.0', 
	array(
		'strategy' => 'defer'
	) 
);

This exact same means of specifying a loading strategy may be achieved via the wp_enqueue_script() function.

Example 2: Specifying that a script be printed in the footer via the new API

The next example showcases a second script being specified for footer printing using the new API, while also supplying the async loading strategy at the same time:

wp_register_script( 
	'bar', 
	'/path/to/bar.js', 
	array(), 
	'1.0.0', 
	array(
		'in_footer' => true,
		'strategy'  => 'async',
	)
)

This exact same means of specifying footer printing may be achieved via the wp_enqueue_script() function.

Implementation details

This feature enhances the existing Scripts API by providing a simple means of specifying a loading strategy by extending commonly used & well known aspects of the Scripts API. It also takes into consideration a script’s dependency tree (its dependencies and/or dependents) when deciding on an “eligible strategy” so as not to result in application of a strategy that is valid for one script but detrimental to others in the tree by causing an unintended out of order of execution. This is near-impossible to achieve via the prior means of adding script loading strategy attributes when using the alternative “hacky” means outlined in the section above.

Technical implementation of the script loading strategy enhancements have been undertaken within the existing Scripts API, notably within the WP_Scripts class, and via enhancements to the familiar and commonly used wp_register_script() and wp_enqueue_script() functions.

A note on dependencies vs dependents
To avoid confusion regarding terminology, let’s clarify the difference between a script’s dependencies vs. its dependents. A script’s dependencies refers to the scripts that said script itself depends on, i.e they must be enqueued prior to said script being enqueued. A script’s dependents on the other hand refers to the scripts that depend on said script, i.e scripts that define said script in their dependencies array.

Changes to the $in_footer parameter of wp_register_script() and wp_enqueue_script() functions

The most notable change to the existing wp_register_script() and wp_enqueue_script() functions is the function signature change, where $in_footer (previously a boolean parameter) has been overloaded to also accept an array $args parameter, with any of the following keys:

  • (bool) in_footer
    • Behaves just like prior implementation of the top level $in_footer param.
  • (string) strategy
    • Accepts an intended loading strategy for the given script being registered/enqueued. Acceptable string values available at the time of implementation are defer for deferred scripts and async for asynchronous scripts.
    • Defaults to blocking behavior, thus retaining backward compatibility for existing script registrations and enqueues.

Retaining backward compatibility

For prior/existing usage of the wp_register_script() and wp_enqueue_script() functions making use of the $in_footer boolean param, backward compatibility is retained via logic that explicitly sets the scripts group to the applicable value for footer or headerHeader The header of your site is typically the first thing people will experience. The masthead or header art located across the top of your page is part of the look and feel of your website. It can influence a visitor’s opinion about your content and you/ your organization’s brand. It may also look different on different screen sizes. printing based on the boolean value passed to the new/overloaded $args parameter. Thus, full backward compatibility is retained and this is a non-breaking enhancement of the API.

While the changes introduced within this feature themselves are considered non-breaking, when making use of the new $args parameter (replacing/overloading the previous $in_footer parameter) in a pluginPlugin A plugin is a piece of software containing a group of functions that can be added to a WordPress website. They can extend functionality or add new features to your WordPress websites. WordPress plugins are written in the PHP programming language and integrate seamlessly with WordPress. These can be free in the WordPress.org Plugin Directory https://wordpress.org/plugins/ or can be cost-based plugin from a third-party/theme/codebase powered by WordPress <6.3, there is one scenario where the $in_footer intention would be misunderstood by core. Take for example the following scenario:

wp_register_script( 
	'foo', 
	'/path/to/foo.js', 
	array(), 
	'1.0.0', 
	array( 
		'strategy'  => 'defer',
		'in_footer' => false, // Note: This is the default value.
	)
);

In WordPress >=6.3 this would be correctly evaluated as being printed in the head via the in_footer array key value being false (which is also the default).

In WordPress versions <6.3, however, the presence of the above array assigned to the $in_footer parameter would itself evaluate to a boolean value of true, the opposite of what may be intended by the developer. That being said, one could rightfully argue that in versions of WordPress that do not support deferred/asynchronous scripts, having them printed in the footer is the next best alternative.

The simplest way to prevent this interoperability problem is to pass the strategy via a different means than the $args param to wp_register_script() or wp_enqueue_script() functions.

It can be passed instead via wp_script_add_data(), in which case it will be understood by WordPress 6.3 but ignored by older versions.

wp_register_script( 
	'foo', 
	'/path/to/foo.js', 
	array(), 
	'1.0.0', 
	false
);
wp_script_add_data( 'foo', 'strategy', 'defer' );

Alternatively, interoperability between WordPress versions newer and older than 6.3 using a single function call per script can be achieved by wrapping script registrations/enqueues within a wrapper function that accounts for new and old function signatures, thus retaining total backward compatibility.

An example of such a script registration/enqueue wrapper may look something as follows:

myplugin_register_script( $handle, $src, $deps, $ver, $args ) { 
    global $wp_version;

    // If >= 6.3, re-use wrapper function signature.
    if ( version_compare( $wp_version,'6.3', '>=' ) ) { 
        wp_register_script(
            $handle,
            $src,
            $deps,
            $ver,
            $args
            );
        } else {
        // Extract in_footer value for older version usage.
        $in_footer = isset( $args['in_footer'] ) ? $args['in_footer'] : false;
        
        wp_register_script(
            $handle,
            $src,
            $deps,
            $ver,
            $in_footer
        );
    }
}

Intended vs. eligible loading strategies

It should be noted that while a developer may intend for a given script to contain a certain loading strategy, the final loading strategy may differ based on factors such as script dependencies/dependents and inline scripts. 

For example, if a developer registers script foo with a strategy of defer, its dependencies must either use a defer or blocking strategy and its dependents must use a defer strategy in order for the intended execution order to be maintained. If a dependent of said script foo is then registered with a blocking intended strategy, script foo and all of its dependencies would then become blocking.

Newly added logic within the WP_Scripts class is responsible for exercising a series of logical checks that ensure that the final strategy for a given script handle is the most eligible strategy based on the factors outlined further above.

A handle will never inherit a strategy that is “more strict” than the one intended, i.e. a script marked for deferred loading will never be changed to asynchronous loading, but the reverse may indeed be the outcome if environmental factors warrant it.

Inline scripts

There are some nuances when applying a loading strategy to scripts (or scripts in the dependency tree) that have inline scripts attached to them, as this ultimately has an effect on the final outcome of an intended/eligible strategy.

Inline scripts that are registered in the before position, remain largely unchanged in behavior, given that they will inherently be parsed and/or executed before the main/parent script is parsed for immediate, deferred or asynchronous execution.

Inline scripts that are registered in the after position (the default for wp_add_inline_script()), however, will affect the final loading strategy of a main/parent script if said main/parent script has an async or deferred eligible strategy. This is largely due to the complexity in ensuring that inline scripts attached to deferred/asynchronous scripts execute at the appropriate and expected time, while not having a negative impact on the parent script itself, and the dependency tree as a whole.

Therefore, if a given script handle contains inline scripts in the after position, this script will be assumed to be blocking and any intended strategy such as defer/async will be removed, with the final eligible strategy being blocking. This, in turn, may affect the script’s dependency tree, and all scripts within it, may too, be treated as blocking scripts in a bid to retain correct execution order and functionality of the enqueued scripts. Logic is explicitly employed to ensure correct execution order of the script tree in these instances.

A follow-up ticketticket Created for both bug reports and feature development on the bug tracker. #58632 has been opened to continue ongoing discussions and proof of concept implementations to potentially introduce delayed execution of inline after scripts in the future which would preserve their loading order.

Migrating to the new API

Code implementations making use of legacy methods of adding async or defer attributes to script tags should migrate to the new API. These include scenarios where the attributes have historically been added to a script tag by way of the script_loader_tag filter, or worse via the clean_url filter.

The following examples show implementation that make use of the script_loader_tag and clean_url filters, which are now considered less-than-ideal approaches, and then show how it should be done using the new API:

MigrationMigration Moving the code, database and media files for a website site from one server to another. Most typically done when changing hosting companies. Consideration Example 1: Adding a defer attribute via the script_loader_tag filter

function old_approach( $tag, $handle ) {
// Only affects foo script.
    if ( 'foo' !== $handle) {
        return $url;
    }

// Modern implementations may employ WP_HTML_Tag_Processor here.
    return str_replace( ' src=', ' defer src=', $tag );
}
add_filter( 'script_loader_tag', old_approach, 10, 2 );

Migration Consideration Example 2: Adding a defer attribute via the clean_url filter

// WARNING: THIS HAS ALWAYS BEEN BRITTLE AND IS NOT RECOMMENDED.
function old_brittle_approach( $url ) {
    // Only affects foo script.
    if ( false === strpos( $url, 'foo.js' ) ) {
        return $url;
    }

    return "$url' defer "; // Assumes single-quoted attributes!
}
add_filter( 'clean_url', 'old_brittle_approach' );

If you are using an approach similar to the one above to add defer or async attributes to a script, please migrate to the new API by using any one of the approaches outlined earlier in this post.

Props: @joemcgill @flixos90 @westonruter @adamsilverstein @jyolsna @stevenlinx

#6-3, #dev-notes, #dev-notes6-3