Skip to content

Everything for WordPress, web development — and beyond

⚡ How to add defer and async for WordPress scripts in function.php

⚡ How to add defer and async for WordPress scripts in function.php

Pages load slowly, Google PageSpeed Insights shows orange warnings, and the client asks: "why is the site slow?" Nine times out of ten the root cause is JavaScript blocking rendering. The browser reaches a <script>, stops building the DOM, loads and executes the script, and only then continues. On a modern site with a dozen plugins this delay turns into seconds.

WordPress for a long time provided no standard way to control script loading. Developers danced around workarounds: filtering script_loader_tag, patching output through clean_url, or even writing custom walkers for WP_Scripts. But with the release of WordPress 6.3 the situation changed radically, and now we have a clean, supported way to add defer or async to any script without a single hack.

Below are two working methods: the modern native approach (WP 6.3+) and the proven script_loader_tag filter (WP 4.1+). Both have been tested on real projects, both preserve dependency queue integrity.

💡 Quick overview:

  • Understand the difference between defer and async and when to use each, this determines whether functionality breaks after optimization
  • Use the native WordPress 6.3+ method via wp_enqueue_script() with the strategy parameter, the cleanest approach that preserves execution order
  • If the site runs a version below 6.3, apply the script_loader_tag filter with an array of handles, this works starting from WordPress 4.1
  • For multiple scripts collect handles into an array and loop through it, one filter for all scripts instead of copy-paste

What defer and async are and when to use them

When a browser encounters a regular <script> tag, it does three things in sequence: stops parsing HTML, loads the script, executes it. Only then does it return to HTML. On a page with five scripts in <head> this means the user sees a white screen while the last comment plugin loads, even though the post itself could have rendered long ago.

The defer and async attributes solve this problem, but work differently:

Attribute

When it loads

When it executes

Execution order

(none)

Blocks parsing immediately

Immediately after loading

In DOM order

defer

Parallel with parsing

After DOM fully loads

In DOM order

async

Parallel with parsing

Immediately after loading

Whoever loads first

Defer is the workhorse for most scenarios. The script loads in parallel with HTML and executes only when the DOM is fully built. Order is preserved: script A will execute before script B, even if B loaded faster. This is critical for jQuery and everything that depends on it.

Async is a tool for independent scripts. Analytics, ads, social media widgets: they don't need the DOM, they don't care about order, they just need to run as soon as possible. But if you put async on a script that depends on jQuery, you'll likely get $ is not defined.

Simple rule: script depends on other scripts or on the DOM → defer. Script is completely autonomous → async. When in doubt, always start with defer.

Method 1: Native WordPress 6.3+ approach

Since July 2023 a new mechanism has been working in WordPress core. The wp_register_script() and wp_enqueue_script() functions received an overloaded fifth parameter $args, an array where you can specify the loading strategy. No filters, no string magic, no risk of breaking dependency order.

Basic syntax for defer:

1wp_enqueue_script(
2 'my-js-handle',
3 get_template_directory_uri() . '/js/my-script.js',
4 array('jquery'),
5 '1.0.0',
6 array(
7 'strategy' => 'defer',
8 'in_footer' => true,
9 )
10);

For async, same mechanics:

1wp_enqueue_script(
2 'google-analytics',
3 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX',
4 array(),
5 '1.0.0',
6 array(
7 'strategy' => 'async',
8 'in_footer' => false,
9 )
10);

The in_footer key inside the array works the same as the old boolean parameter: true puts the script in the footer, false in <head>. For defer you typically set true (the script waits for DOM anyway, no point loading it early), for async whatever works.

The main advantage of the native method is that core itself checks the dependency tree. If script A with defer depends on script B, and B is registered without a strategy (blocking), WordPress won't break your site: it will automatically downgrade script A's strategy to blocking. When using script_loader_tag you lack this protection, the filter simply inserts the attribute without looking at dependencies.

Important: the $args array appeared in WordPress 6.3. If a theme or plugin must work on lower versions, use method 2 or add a check:

1if ( version_compare( $GLOBALS['wp_version'], '6.3', '>=' ) ) {
2 // native method
3} else {
4 // script_loader_tag filter
5}

Method 2: script_loader_tag filter (WordPress 4.1+)

If the site runs a version below 6.3 or you need to maintain backward compatibility, apply the proven script_loader_tag filter. It has existed since WordPress 4.1 and still works flawlessly.

The filter fires right before the <script> tag is output to HTML, you receive the ready tag string, the script handle, and the file path, and can replace src with defer="defer" src or async="async" src.

Single script with defer:

1function add_defer_to_my_script($tag, $handle) {
2 if ( 'my-js-handle' !== $handle ) {
3 return $tag;
4 }
5 return str_replace( ' src', ' defer="defer" src', $tag );
6}
7add_filter('script_loader_tag', 'add_defer_to_my_script', 10, 2);

The code goes in the active theme's functions.php or, more correctly, in a separate snippet plugin like Code Snippets or WPCode. If you put it in a child theme's functions.php, when you switch themes the scripts will become blocking again, and you won't notice immediately.

The script handle is the first parameter you passed to wp_register_script() or wp_enqueue_script(). This is what appears in the if condition. Don't guess the handle, open the plugin or theme source code and find the wp_enqueue_script call.

Defer and async for multiple scripts

Adding one filter per script is a path to bloated functions.php and copy-paste errors. The right solution: an array of handles and one filter with a loop.

1function add_defer_to_scripts($tag, $handle) {
2 $scripts_to_defer = array(
3 'my-js-handle',
4 'another-handle',
5 'third-party-lib',
6 );
7
8 foreach ( $scripts_to_defer as $defer_script ) {
9 if ( $defer_script === $handle ) {
10 return str_replace( ' src', ' defer="defer" src', $tag );
11 }
12 }
13 return $tag;
14}
15add_filter('script_loader_tag', 'add_defer_to_scripts', 10, 2);

For async, only the attribute and array name change:

1function add_async_to_scripts($tag, $handle) {
2 $scripts_to_async = array(
3 'google-tag-manager',
4 'facebook-pixel',
5 'hotjar',
6 );
7
8 foreach ( $scripts_to_async as $async_script ) {
9 if ( $async_script === $handle ) {
10 return str_replace( ' src', ' async="async" src', $tag );
11 }
12 }
13 return $tag;
14}
15add_filter('script_loader_tag', 'add_async_to_scripts', 10, 2);

Both filters can be hooked simultaneously, defer on your scripts, async on third-party trackers. They work independently and don't conflict.

Practical example: Google Maps API

Google Maps is a classic candidate for defer. The map is typically in the footer of the contacts page, the script pulls 100+ KB, and the user doesn't need the map right away. Moreover, the API itself doesn't depend on other page scripts, an ideal case.

Connect and defer:

1// theme's functions.php
2function enqueue_google_maps() {
3 if ( ! is_page('contacts') ) {
4 return;
5 }
6
7 wp_enqueue_script(
8 'google-maps-api',
9 'https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY',
10 array(),
11 null,
12 array(
13 'strategy' => 'defer',
14 'in_footer' => true,
15 )
16 );
17}
18add_action('wp_enqueue_scripts', 'enqueue_google_maps');

Same result via script_loader_tag:

1function add_defer_to_google_maps($tag, $handle) {
2 if ( 'google-maps-api' !== $handle ) {
3 return $tag;
4 }
5 return str_replace( ' src', ' defer="defer" src', $tag );
6}
7add_filter('script_loader_tag', 'add_defer_to_google_maps', 10, 2);

After installing either variant, definitely check the map on the contacts page. Open the browser console (F12), make sure there are no JavaScript errors, and that the map rendered correctly. If you get an error like initMap is not a function, it means your initialization script also needs to be marked as defer and placed strictly after the API connection.

How to verify defer and async are working

After implementation comes verification. Without it you don't know whether the optimization worked or just sits as dead code.

Open the page source (Ctrl+U) and find your scripts. The <script> tag should have the attributes:

1<script defer="defer" src="/wp-content/themes/my-theme/js/my-script.js"></script>

If there are no attributes, check whether the handle in the filter matches the actual script handle. Common mistake: in wp_enqueue_script the handle is my-plugin-frontend, but in the filter it's my_plugin_frontend. Hyphen versus underscore, and the filter silently skips the script.

Final touch, Google PageSpeed Insights or Lighthouse in the Audits tab of developer tools. The "Eliminate render-blocking resources" section should show improvement. Specific gain depends on the number and size of scripts, but for a typical WordPress site with 5-7 plugins a 40-60% reduction in blocking JavaScript is an achievable result.

⁉️🤔 Frequently asked questions

Can I use both defer and async on one script?

No. If you specify both attributes simultaneously, the browser will ignore defer and execute the script as async. This behavior is baked into the HTML specification, async always takes priority. Choose one based on whether execution order matters.

What to do if after adding defer the script stopped working?

Most likely the script expects the DOM to not yet be built and tries to manipulate elements that don't exist at execution time. Replace defer with standard blocking loading for that specific script. Or wrap the script code in DOMContentLoaded, then it can work with defer without errors. The second option is preferable: you keep the optimization and fix compatibility.

What's the difference between defer and moving the script to the footer via wp_enqueue_script with $in_footer = true?

$in_footer = true merely moves the <script> tag from <head> to the end of <body>. The script still blocks rendering, just later. defer loads in parallel with HTML parsing and executes strictly after the DOM is built. Combined use (in_footer => true + strategy => 'defer') gives maximum effect: the script in the footer doesn't delay first render, and defer guarantees it won't block final rendering either.

Should I update WordPress to 6.3 just for the native method?

If the site is on version 6.2 or older, updating is worthwhile not just for strategy. WordPress 6.3 closed dozens of vulnerabilities and brought core performance improvements. But if an update is impossible for some reason, the script_loader_tag filter works absolutely reliably from version 4.1, released in 2014. You lose nothing using it.

What about jQuery, defer or leave as is?

jQuery should load with defer if all dependent scripts are also marked defer. The problem is that WordPress plugins extremely rarely manage attributes for their scripts. If you put defer on jQuery while a contact form plugin connects its script without attributes, the browser will execute the plugin before jQuery and the form will break. Practical advice: start with defer for your own theme scripts. Don't touch jQuery until you've tested every plugin on the site.

What to put on a production site in 2026

If the server runs WordPress 6.3 or newer, only the native method. Clean code, protection from dependency conflicts, core support. Start with defer for all theme scripts and critically important plugins; reserve async for analytics and third-party widgets.

If the version is below 6.3, the script_loader_tag filter with an array of handles. It's worked for a decade, nothing to break. The only thing it can't do is automatically check the dependency tree, so add scripts to the array one at a time and check the site after each.

And most importantly: no method replaces auditing the scripts themselves. If a gallery plugin connects 15 files just to show three images, neither defer nor async will radically help. Loading optimization starts with the question "is this script even needed," and only then, "how to load it."

🔗 Official WordPress 6.3 documentation, Script Loading Strategies