Changing How WordPress Prints a Script Tag
Deferring a script with this filter detaches it from the dependency order WordPress maintains, and any inline script attached to it runs before it loads. Core has had a strategy argument since 6.3 that handles both; the filter remains for what the argument cannot express.
The filter edits the HTML of one script tag, and nothing else about how WordPress loads scripts knows you did. script_loader_tag receives the full <script> tag, the handle and the source URL. Change the string and the change is printed. The dependency graph that decided the tag's position, and the inline scripts attached to the handle, are untouched — which is why adding defer with this filter has broken more sites than it has sped up.
The filter as it is usually written
add_filter( 'script_loader_tag', 'ti_defer_script', 10, 3 );
function ti_defer_script( $tag, $handle, $src ) {
if ( 'ti-analytics' !== $handle ) {
return $tag;
}
return str_replace( ' src=', ' defer src=', $tag );
}
That works for a script with no dependents and no inline code. The str_replace on src= is fragile — WordPress prints src with a leading space, but a plugin earlier in the filter chain may have rewritten the tag — and most tutorials show it anyway. A regex anchored on the tag's id="ti-analytics-js" is safer. Rebuilding the whole tag with wp_get_script_tag() is not: $tag also carries the handle's before and after inline scripts and translations, which a rebuilt tag drops.
What the filter cannot see
| Concern | Filter | Strategy argument |
|---|---|---|
| Dependents of a deferred script | Still execute in print order and break | Dependents are deferred too, or the strategy is downgraded |
wp_add_inline_script() before the handle |
Runs before the file, which is fine | Same |
wp_add_inline_script() after the handle |
Runs before the deferred file executes | Strategy silently dropped: the script prints blocking |
| Scripts in the footer | Printed as usual with your attribute | Same |
| Handle that is a dependency of a blocking script | Deferred anyway, breaking the blocking one | Strategy ignored for that handle |
Since WordPress 6.3 the fifth argument of wp_enqueue_script() — the enqueue call covered in wp_enqueue_scripts, where that position used to be the footer boolean — accepts an array:
wp_enqueue_script(
'ti-analytics',
plugins_url( 'js/analytics.js', __FILE__ ),
array(),
'2.0.0',
array( 'strategy' => 'defer', 'in_footer' => true )
);
WordPress then resolves the strategy against the dependency graph. A deferred script that something blocking depends on is printed blocking; an async script whose dependents are deferred is downgraded to defer; and a handle with inline code attached after it is not eligible for any delayed strategy at all — the attribute is dropped and the script prints blocking, with no warning. Move such code into the file, attach it before, or wrap it in a DOMContentLoaded listener. None of that resolution is available through the tag filter, and all of it is what the filter's users were trying to get. Loading a script non-blocking is the first step in fixing render-blocking resources, and the strategy argument is the way to take it.
What still needs the filter
type="module"on sites below 6.5, or for scripts that cannot use the Script Modules API. Replace or add the type attribute for your handle only.crossorigin,integrity,nonceattributes for a content security policy. Since 6.4 enqueued scripts are printed throughwp_get_script_tag(), so thewp_script_attributesfilter sees them and is the cleaner hook for adding attributes; it receives no handle, so identify the script by$attributes['id'] === 'my-handle-js'.- Removing
type="text/javascript"on themes that have not declared HTML5 script support; the right fix isadd_theme_support( 'html5', array( 'script', 'style' ) ), after which the attribute is not printed at all. - Third-party scripts registered by plugins without a strategy, where you cannot change the enqueue call. Here
wp_script_add_data( $handle, 'strategy', 'defer' )on a later hook applies core's resolution to someone else's handle, and the tag filter is the fallback. Either way the dependency check is on you: loopwp_scripts()->registeredand look for your handle in each entry'sdepsbefore deferring.
Styles
style_loader_tag is the companion for <link> tags, with the same arguments plus the media attribute. The classic use is turning a stylesheet into a preload with an onload swap, which has the same ordering hazards and is better expressed with wp_enqueue_style plus a print-media trick only when measured to help. Changing the tag does not change the cascade order WordPress decided from dependencies.
Verify
- View source: the handle's tag carries the attribute, and no other tag does.
- Open the console with the deferred script's dependents in use: no "undefined" errors on load.
- Check inline code attached to the handle: it runs after the file, not before.
- Run the page through a performance audit and compare the blocking time before and after; a deferred script that was already in the footer rarely changes the number, and the change was a risk taken for nothing. Clear the page cache before measuring, or the audit compares two cached copies.
Frequently asked
- Because the second script depends on the first and WordPress printed them in dependency order, assuming both execute in order. A deferred script executes after parsing, so the dependent script runs first and finds nothing. The strategy argument handles this by deferring the whole chain; the filter does not.
- Inline code added 'after' a handle is printed as a separate tag immediately following the script, and runs when the parser reaches it — before the deferred script has executed. The strategy argument refuses to defer such a handle and prints it blocking instead, so the fix is to move the inline code into the file, attach it 'before', or wrap it in a DOMContentLoaded listener.
- The filter is one way: replace the opening tag's type or add the attribute for your handle. Since 6.5 the Script Modules API, wp_enqueue_script_module(), registers true modules with an import map and is the better route for new code.