Defer Parsing of JavaScript in WordPress Without a Plugin

Quick Summary
- For your own scripts, pass array( 'strategy' => 'defer' ) as the last argument of wp_enqueue_script(); WordPress 6.3 and later add the defer attribute whenever that is safe.
- For plugin and theme scripts, set the same strategy with wp_script_add_data() on wp_enqueue_scripts, so WordPress still checks dependents and inline scripts before deferring.
- The older script_loader_tag filter adds defer to every tag without those checks, so scripts followed by inline code, such as wp-i18n, break; test with the browser console open.
To defer JavaScript in WordPress without a plugin, pass 'strategy' => 'defer' when you enqueue your own scripts (WordPress 6.3 and later), and add defer to plugin and theme scripts from your child theme’s functions.php. A deferred script downloads while the page is parsed and runs once the HTML is ready, in its original order, so it no longer blocks rendering.
PageSpeed Insights reports scripts that still hold up the first render under Render-blocking requests. For scripts, that means a <script> in the <head> without defer or async.
How do you defer your own scripts?
Since WordPress 6.3, the last argument of wp_enqueue_script() and wp_register_script() can be an array. Its strategy key takes 'defer' or 'async', and its in_footer key does the job of the old true/false argument, which still works. In your child theme’s functions.php, with your own handle and file:
function mytheme_enqueue_scripts() {
wp_enqueue_script(
'mytheme-slider',
get_stylesheet_directory_uri() . '/js/slider.js',
array( 'jquery' ),
'1.0',
array( 'strategy' => 'defer' )
);
}
add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_scripts' );WordPress 7.1 prints it in the <head> like this, after jQuery’s two normal script tags:
<script data-wp-strategy="defer" defer id="mytheme-slider-js" src="https://example.com/wp-content/themes/mytheme/js/slider.js?ver=1.0"></script>jQuery itself stays a normal script. That’s fine: a deferred script may depend on a blocking one, because the blocking one has already run by the time the deferred one runs.
When does WordPress ignore the defer strategy?
The strategy is a request, not an order. Before printing a script, WordPress checks the scripts around it (WP_Scripts::get_eligible_loading_strategy() in wp-includes/class-wp-scripts.php) and falls back to a normal, blocking script when deferring could make code run in the wrong order:
- Dependents decide. A script is only deferred when every enqueued script that depends on it, and every script depending on those, is deferred or async too. One dependent without a strategy makes it blocking.
- Inline code after it blocks it. A script with inline code added by
wp_add_inline_script()in the default'after'position is never delayed, because that code expects the file to have run already. Inline code in the'before'position is fine. - async can become defer. An async script with a deferred dependent is printed with
defer, so the order still holds. - What you see. The tag keeps
data-wp-strategy="defer", the strategy you asked for, but gets nodeferattribute. A script in the<head>that falls back is moved to the footer when everything that depends on it is printed there. A value other than'defer'or'async'is ignored with a “doing it wrong” notice.
So when the page source shows data-wp-strategy="defer" without a defer attribute, WordPress decided that deferring that script wasn’t safe.
How do you defer scripts from plugins and themes?
You can’t edit their wp_enqueue_script() calls, but you can set the same strategy on their handles once they are registered, and WordPress then applies the same checks. This defers every front-end script except jQuery:
function defer_plugin_and_theme_scripts() {
if ( is_user_logged_in() ) return; //leave page builders and the admin bar alone
$skip = array( 'jquery-core', 'jquery-migrate' );
foreach ( wp_scripts()->registered as $handle => $script ) {
if ( ! $script->src || in_array( $handle, $skip, true ) ) continue;
if ( ! wp_scripts()->get_data( $handle, 'strategy' ) ) {
wp_script_add_data( $handle, 'strategy', 'defer' );
}
}
}
add_action( 'wp_enqueue_scripts', 'defer_plugin_and_theme_scripts', PHP_INT_MAX );- It runs last on
wp_enqueue_scripts(priorityPHP_INT_MAX), after themes and plugins have registered their scripts. A script that a plugin registers later, while the page content is rendered, isn’t in the list yet and prints as before. wp_script_add_data()with'strategy'is the documented way to add a strategy to a script that is already registered.- Handles without a
srcare skipped. They are aliases such asjquery, which only groupsjquery-coreandjquery-migrate, and WordPress refuses a strategy for them. - Scripts that already have a strategy keep it, and logged-in users get the page unchanged.
To defer a single script instead, call wp_script_add_data( 'its-handle', 'strategy', 'defer' ); in a function on the same hook. The handle is the tag’s id without -js: id="some-plugin-js" belongs to the handle some-plugin.
The script_loader_tag filter
This post’s original snippet edits the finished tag instead. It goes in the same functions.php:
function defer_parsing_of_js( $url, $handle ) {
if ( is_user_logged_in() ) return $url; //don't break WP Admin
if ( FALSE === strpos( $url, '.js' ) ) return $url;
if ( in_array( $handle, array( 'jquery-core', 'jquery-migrate' ), true ) ) return $url;
if ( wp_scripts()->get_data( $handle, 'strategy' ) ) return $url; //WordPress handles these
return str_replace( ' src', ' defer src', $url );
}
add_filter( 'script_loader_tag', 'defer_parsing_of_js', 10, 2 );- The
script_loader_tagfilter passes the full<script>tag of every enqueued script through the function before WordPress prints it. The first argument is that tag, even though the function calls it$url. - Logged-in users get the tags unchanged, so the dashboard keeps working. Test the result logged out, for example in a private window.
- Tags without
.jsare returned as they are, and so are jQuery’s two files. The filter passes the script handle as its second argument (the2inadd_filter()asks for it), and WordPress registers jQuery asjquery-coreandjquery-migrate. Checking the handle works whatever the file is called: WordPress loadsjquery.min.js, notjquery.js, unlessSCRIPT_DEBUGis on. - Scripts that already have a strategy are left to WordPress. Without that line, a script enqueued with
'strategy' => 'defer'ends up with twodeferattributes. str_replace()addsdeferto all the other tags.
The filter doesn’t know the rules above: it defers a script even when inline code printed right after it needs it. WordPress’s translation script, wp-i18n, is one, and every script that uses wp_set_script_translations() depends on it. With the filter on WordPress 7.1, Chrome reports Uncaught ReferenceError: wp is not defined from its inline code, and a plugin script with its own inline code fails the same way. The wp_script_add_data() version keeps those scripts and the ones they depend on blocking, and the same page loads without errors. Adding only the script with inline code to the filter’s list doesn’t help, because the scripts it depends on are still deferred and run after it. So prefer the wp_script_add_data() version, and if you keep the filter, check the browser console while logged out.
Common problems
- Inline scripts fail. Code printed directly in the page, by a widget, a page builder or your template, runs as soon as the browser reaches it, before any deferred file has loaded, so calls to a deferred library fail. Leave that library out (add its handle to
$skip; WordPress then keeps what it depends on blocking too), or move the inline code into a file that lists the library as a dependency. - Some scripts don’t change. Only scripts enqueued through WordPress pass through these snippets; a
<script>tag typed into a template or a widget doesn’t. Script modules (type="module") are printed separately and are deferred by the browser anyway. A page cache can also keep serving the old HTML until you clear it. - A script stays blocking. If its tag has
data-wp-strategy="defer"but nodefer, something that depends on it isn’t deferred, or it has inline code after it. See the rules above.
Should you use async instead of defer?
Async scripts run as soon as they have downloaded, in no fixed order, which suits independent scripts such as analytics. Deferred scripts keep their order, which matters for code that depends on jQuery or on another script. In WordPress, 'strategy' => 'async' only stays async when everything that depends on the script is async too; with a deferred dependent it is printed with defer, and with a blocking one it gets neither.
Is there a plugin that does this?
If you prefer a plugin, Autoptimize is actively maintained and can defer scripts: under Settings > Autoptimize, turn on Optimize JavaScript Code? and Do not aggregate but defer?. Its Also defer inline JS? option defers inline code as well. Check your pages after changing these settings.
Related articles
- keeping text visible while web fonts load: the font-display fix for another PageSpeed warning
- browser cache headers in .htaccess: longer caching for your scripts and styles

