
⚡ 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
deferandasyncand when to use each, this determines whether functionality breaks after optimization - Use the native WordPress 6.3+ method via
wp_enqueue_script()with thestrategyparameter, the cleanest approach that preserves execution order - If the site runs a version below 6.3, apply the
script_loader_tagfilter 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 |
| Parallel with parsing | After DOM fully loads | In DOM order |
| 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:
1 wp_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:
1 wp_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:
1 if ( 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:
1 function 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 } 7 add_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.
1 function 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 } 15 add_filter('script_loader_tag', 'add_defer_to_scripts', 10, 2);
For async, only the attribute and array name change:
1 function 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 } 15 add_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 2 function 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 } 18 add_action('wp_enqueue_scripts', 'enqueue_google_maps');
Same result via script_loader_tag:
1 function 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 } 7 add_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
deferand execute the script asasync. This behavior is baked into the HTML specification,asyncalways 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
deferwith standard blocking loading for that specific script. Or wrap the script code inDOMContentLoaded, then it can work withdeferwithout 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 = truemerely moves the<script>tag from<head>to the end of<body>. The script still blocks rendering, just later.deferloads 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, thescript_loader_tagfilter 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
deferif all dependent scripts are also markeddefer. The problem is that WordPress plugins extremely rarely manage attributes for their scripts. If you putdeferon 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 withdeferfor 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



