defer-scripts.php

I’ll build this as a drop-in performance/defer-scripts.php module for your RX theme, with safe defaults, admin exclusions, critical script protection, defer/async controls, preload helpers, and compatibility guards.

Below is an advanced rx theme / performance/defer-scripts.php file.
Create this file:

rx-theme/
└── performance/
    └── defer-scripts.php

Then include it in your functions.php:

require_once get_template_directory() . '/performance/defer-scripts.php';

performance/defer-scripts.php

<?php
/**
 * RX Theme - Advanced Script Defer / Async / Preload Manager
 *
 * File: performance/defer-scripts.php
 *
 * Features:
 * - Adds defer to safe frontend scripts
 * - Optional async support by handle list
 * - Excludes jQuery, admin, login, customizer, REST, AMP, WooCommerce checkout/cart
 * - Protects critical WordPress and plugin scripts
 * - Supports per-handle defer, async, module, nomodule, preload, preconnect, dns-prefetch
 * - Adds fetchpriority where useful
 * - Supports script loading strategy for modern WordPress
 * - Adds safe filters for child theme/plugin extension
 * - Avoids breaking inline dependent scripts
 * - Can remove query strings from static assets
 * - Can move selected footer scripts
 * - Can delay selected third-party scripts
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! class_exists( 'RX_Theme_Defer_Scripts' ) ) :

final class RX_Theme_Defer_Scripts {

	/**
	 * Enable or disable the whole module.
	 */
	private static bool $enabled = true;

	/**
	 * Default handles that should never be deferred or async.
	 */
	private static array $never_defer_handles = array(
		'jquery',
		'jquery-core',
		'jquery-migrate',
		'wp-polyfill',
		'wp-hooks',
		'wp-i18n',
		'wp-element',
		'wp-components',
		'wp-blocks',
		'wp-block-library',
		'wp-dom-ready',
		'wp-api-fetch',
		'wp-data',
		'wp-date',
		'wp-edit-post',
		'wp-editor',
		'wp-rich-text',
		'wp-compose',
		'wp-url',
		'wp-util',
		'underscore',
		'backbone',
		'mediaelement',
		'mediaelement-core',
		'mediaelement-migrate',
		'wp-mediaelement',
		'comment-reply',
		'admin-bar',
		'customize-preview',
		'customize-controls',
		'customize-selective-refresh',
	);

	/**
	 * Handles that are safe to defer by default.
	 * Empty means auto-detect and defer most safe frontend scripts.
	 */
	private static array $defer_handles = array();

	/**
	 * Handles that should use async instead of defer.
	 * Use async carefully only for independent third-party scripts.
	 */
	private static array $async_handles = array(
		'google-analytics',
		'google-tag-manager',
		'gtag',
		'facebook-pixel',
		'fb-pixel',
	);

	/**
	 * Handles that should be treated as JavaScript modules.
	 */
	private static array $module_handles = array();

	/**
	 * Handles that should have nomodule.
	 */
	private static array $nomodule_handles = array();

	/**
	 * Handles to preload.
	 */
	private static array $preload_handles = array();

	/**
	 * Handles to delay until user interaction.
	 */
	private static array $delay_handles = array();

	/**
	 * URL keyword exclusions.
	 */
	private static array $excluded_url_keywords = array(
		'/wp-admin/',
		'/wp-includes/js/jquery/',
		'jquery.min.js',
		'jquery-migrate',
		'customize-preview',
		'customize-controls',
		'wp-emoji',
		'recaptcha',
		'google.com/recaptcha',
		'gstatic.com/recaptcha',
		'stripe.com',
		'paypal.com',
		'checkout',
		'cart-fragments',
	);

	/**
	 * Third-party hosts that may be delayed if handle is in delay list.
	 */
	private static array $delay_url_keywords = array(
		'googletagmanager.com',
		'google-analytics.com',
		'connect.facebook.net',
		'facebook.com/tr',
		'hotjar.com',
		'clarity.ms',
		'doubleclick.net',
		'tiktok.com',
		'pinimg.com',
	);

	/**
	 * Preconnect URLs.
	 */
	private static array $preconnect_urls = array(
		'https://fonts.googleapis.com',
		'https://fonts.gstatic.com',
	);

	/**
	 * DNS prefetch URLs.
	 */
	private static array $dns_prefetch_urls = array(
		'//fonts.googleapis.com',
		'//fonts.gstatic.com',
		'//www.googletagmanager.com',
		'//www.google-analytics.com',
	);

	/**
	 * Initialize module.
	 */
	public static function init(): void {

		self::$enabled = (bool) apply_filters( 'rx_defer_scripts_enabled', self::$enabled );

		if ( ! self::$enabled ) {
			return;
		}

		add_filter( 'script_loader_tag', array( __CLASS__, 'filter_script_loader_tag' ), 20, 3 );
		add_filter( 'script_loader_src', array( __CLASS__, 'maybe_remove_script_query_strings' ), 20, 2 );

		add_action( 'wp_enqueue_scripts', array( __CLASS__, 'apply_script_loading_strategy' ), 999 );
		add_action( 'wp_head', array( __CLASS__, 'print_resource_hints' ), 1 );
		add_action( 'wp_head', array( __CLASS__, 'print_script_preloads' ), 2 );
		add_action( 'wp_footer', array( __CLASS__, 'print_delay_script_runtime' ), 1 );

		add_filter( 'wp_resource_hints', array( __CLASS__, 'add_wp_resource_hints' ), 10, 2 );

		/*
		 * Optional footer movement.
		 * This can improve render speed but may break badly coded scripts.
		 */
		add_action( 'wp_enqueue_scripts', array( __CLASS__, 'move_selected_scripts_to_footer' ), 1000 );
	}

	/**
	 * Check whether current request is frontend-safe.
	 */
	private static function is_safe_frontend_context(): bool {

		if ( is_admin() ) {
			return false;
		}

		if ( wp_doing_ajax() ) {
			return false;
		}

		if ( wp_doing_cron() ) {
			return false;
		}

		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return false;
		}

		if ( is_customize_preview() ) {
			return false;
		}

		if ( function_exists( 'is_amp_endpoint' ) && is_amp_endpoint() ) {
			return false;
		}

		if ( function_exists( 'is_cart' ) && is_cart() ) {
			return false;
		}

		if ( function_exists( 'is_checkout' ) && is_checkout() ) {
			return false;
		}

		if ( function_exists( 'is_account_page' ) && is_account_page() ) {
			return false;
		}

		return true;
	}

	/**
	 * Main script tag filter.
	 */
	public static function filter_script_loader_tag( string $tag, string $handle, string $src ): string {

		if ( ! self::is_safe_frontend_context() ) {
			return $tag;
		}

		if ( empty( $src ) ) {
			return $tag;
		}

		if ( self::has_attribute( $tag, 'defer' ) || self::has_attribute( $tag, 'async' ) ) {
			return $tag;
		}

		if ( self::should_never_modify( $handle, $src ) ) {
			return $tag;
		}

		$tag = self::maybe_add_module_attribute( $tag, $handle );
		$tag = self::maybe_add_nomodule_attribute( $tag, $handle );
		$tag = self::maybe_add_fetchpriority_attribute( $tag, $handle, $src );

		if ( self::should_delay_script( $handle, $src ) ) {
			return self::build_delayed_script_tag( $tag, $handle, $src );
		}

		if ( self::should_async_script( $handle, $src ) ) {
			return self::add_attribute_to_script_tag( $tag, 'async', 'async' );
		}

		if ( self::should_defer_script( $handle, $src ) ) {
			return self::add_attribute_to_script_tag( $tag, 'defer', 'defer' );
		}

		return $tag;
	}

	/**
	 * Apply WordPress native loading strategy where possible.
	 */
	public static function apply_script_loading_strategy(): void {

		if ( ! self::is_safe_frontend_context() ) {
			return;
		}

		global $wp_scripts;

		if ( ! $wp_scripts instanceof WP_Scripts ) {
			return;
		}

		foreach ( (array) $wp_scripts->registered as $handle => $script ) {

			if ( empty( $script->src ) ) {
				continue;
			}

			$src = self::normalize_script_src( $script->src );

			if ( self::should_never_modify( $handle, $src ) ) {
				continue;
			}

			if ( self::should_async_script( $handle, $src ) ) {
				wp_script_add_data( $handle, 'strategy', 'async' );
				continue;
			}

			if ( self::should_defer_script( $handle, $src ) ) {
				wp_script_add_data( $handle, 'strategy', 'defer' );
			}
		}
	}

	/**
	 * Move selected safe scripts to footer.
	 */
	public static function move_selected_scripts_to_footer(): void {

		if ( ! self::is_safe_frontend_context() ) {
			return;
		}

		$footer_handles = apply_filters( 'rx_footer_script_handles', array() );

		if ( empty( $footer_handles ) || ! is_array( $footer_handles ) ) {
			return;
		}

		global $wp_scripts;

		if ( ! $wp_scripts instanceof WP_Scripts ) {
			return;
		}

		foreach ( $footer_handles as $handle ) {
			if ( isset( $wp_scripts->registered[ $handle ] ) ) {
				$wp_scripts->registered[ $handle ]->args = 1;
			}
		}
	}

	/**
	 * Should this script never be changed?
	 */
	private static function should_never_modify( string $handle, string $src ): bool {

		$never = apply_filters(
			'rx_never_defer_script_handles',
			self::$never_defer_handles
		);

		if ( in_array( $handle, $never, true ) ) {
			return true;
		}

		$excluded_keywords = apply_filters(
			'rx_defer_excluded_url_keywords',
			self::$excluded_url_keywords
		);

		foreach ( $excluded_keywords as $keyword ) {
			if ( false !== stripos( $src, $keyword ) ) {
				return true;
			}
		}

		/*
		 * WooCommerce important scripts.
		 */
		if ( false !== stripos( $handle, 'wc-' ) ) {
			$wc_safe = apply_filters( 'rx_allow_defer_woocommerce_scripts', false, $handle, $src );

			if ( ! $wc_safe ) {
				return true;
			}
		}

		/*
		 * Elementor editor/frontend protections.
		 */
		if ( false !== stripos( $handle, 'elementor' ) ) {
			$elementor_safe = apply_filters( 'rx_allow_defer_elementor_scripts', false, $handle, $src );

			if ( ! $elementor_safe ) {
				return true;
			}
		}

		/*
		 * Divi/Builder protections.
		 */
		if (
			false !== stripos( $handle, 'et-' ) ||
			false !== stripos( $handle, 'divi' )
		) {
			$builder_safe = apply_filters( 'rx_allow_defer_builder_scripts', false, $handle, $src );

			if ( ! $builder_safe ) {
				return true;
			}
		}

		return (bool) apply_filters( 'rx_should_never_modify_script', false, $handle, $src );
	}

	/**
	 * Should defer?
	 */
	private static function should_defer_script( string $handle, string $src ): bool {

		$explicit_defer = apply_filters( 'rx_defer_script_handles', self::$defer_handles );

		if ( ! empty( $explicit_defer ) && is_array( $explicit_defer ) ) {
			return in_array( $handle, $explicit_defer, true );
		}

		/*
		 * Auto mode:
		 * Defer theme/plugin/frontend scripts except protected ones.
		 */
		if ( self::is_local_theme_or_plugin_script( $src ) ) {
			return true;
		}

		/*
		 * Defer common CDN libraries, but only when not excluded.
		 */
		if ( self::is_common_safe_cdn_script( $src ) ) {
			return true;
		}

		return (bool) apply_filters( 'rx_should_defer_script', false, $handle, $src );
	}

	/**
	 * Should async?
	 */
	private static function should_async_script( string $handle, string $src ): bool {

		$async_handles = apply_filters( 'rx_async_script_handles', self::$async_handles );

		if ( in_array( $handle, $async_handles, true ) ) {
			return true;
		}

		$async_keywords = apply_filters(
			'rx_async_script_url_keywords',
			array(
				'googletagmanager.com/gtag/js',
				'google-analytics.com/analytics.js',
				'connect.facebook.net',
			)
		);

		foreach ( $async_keywords as $keyword ) {
			if ( false !== stripos( $src, $keyword ) ) {
				return true;
			}
		}

		return (bool) apply_filters( 'rx_should_async_script', false, $handle, $src );
	}

	/**
	 * Should delay until interaction?
	 */
	private static function should_delay_script( string $handle, string $src ): bool {

		$delay_handles = apply_filters( 'rx_delay_script_handles', self::$delay_handles );

		if ( in_array( $handle, $delay_handles, true ) ) {
			return true;
		}

		$delay_keywords = apply_filters( 'rx_delay_script_url_keywords', self::$delay_url_keywords );

		foreach ( $delay_keywords as $keyword ) {
			if ( false !== stripos( $src, $keyword ) ) {
				return (bool) apply_filters( 'rx_auto_delay_third_party_scripts', false, $handle, $src );
			}
		}

		return (bool) apply_filters( 'rx_should_delay_script', false, $handle, $src );
	}

	/**
	 * Convert script tag into delayed script tag.
	 */
	private static function build_delayed_script_tag( string $tag, string $handle, string $src ): string {

		$src_escaped    = esc_url( $src );
		$handle_escaped = esc_attr( $handle );

		return sprintf(
			'<script type="text/rx-delayed-script" data-rx-handle="%1$s" data-src="%2$s"></script>' . "\n",
			$handle_escaped,
			$src_escaped
		);
	}

	/**
	 * Runtime loader for delayed scripts.
	 */
	public static function print_delay_script_runtime(): void {

		if ( ! self::is_safe_frontend_context() ) {
			return;
		}

		$enabled = apply_filters( 'rx_delay_script_runtime_enabled', true );

		if ( ! $enabled ) {
			return;
		}

		?>
		<script id="rx-delay-script-runtime">
		(function() {
			'use strict';

			var loaded = false;
			var events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'wheel'];

			function loadDelayedScripts() {
				if (loaded) {
					return;
				}

				loaded = true;

				var scripts = document.querySelectorAll('script[type="text/rx-delayed-script"][data-src]');

				scripts.forEach(function(oldScript) {
					var newScript = document.createElement('script');
					var src = oldScript.getAttribute('data-src');

					if (!src) {
						return;
					}

					newScript.src = src;
					newScript.defer = true;

					if (oldScript.dataset.rxHandle) {
						newScript.setAttribute('data-rx-handle', oldScript.dataset.rxHandle);
					}

					oldScript.parentNode.insertBefore(newScript, oldScript.nextSibling);
					oldScript.remove();
				});

				removeEvents();
			}

			function removeEvents() {
				events.forEach(function(eventName) {
					window.removeEventListener(eventName, loadDelayedScripts, passiveOptions());
				});
			}

			function passiveOptions() {
				try {
					return { passive: true };
				} catch (e) {
					return false;
				}
			}

			events.forEach(function(eventName) {
				window.addEventListener(eventName, loadDelayedScripts, passiveOptions());
			});

			window.addEventListener('load', function() {
				setTimeout(loadDelayedScripts, 3500);
			});
		})();
		</script>
		<?php
	}

	/**
	 * Preconnect and DNS prefetch.
	 */
	public static function print_resource_hints(): void {

		if ( ! self::is_safe_frontend_context() ) {
			return;
		}

		$preconnect_urls = apply_filters( 'rx_preconnect_urls', self::$preconnect_urls );
		$dns_urls        = apply_filters( 'rx_dns_prefetch_urls', self::$dns_prefetch_urls );

		foreach ( $preconnect_urls as $url ) {
			printf(
				'<link rel="preconnect" href="%s" crossorigin>' . "\n",
				esc_url( $url )
			);
		}

		foreach ( $dns_urls as $url ) {
			printf(
				'<link rel="dns-prefetch" href="%s">' . "\n",
				esc_url( $url )
			);
		}
	}

	/**
	 * Add WP resource hints.
	 */
	public static function add_wp_resource_hints( array $urls, string $relation_type ): array {

		if ( 'preconnect' === $relation_type ) {
			foreach ( apply_filters( 'rx_preconnect_urls', self::$preconnect_urls ) as $url ) {
				$urls[] = array(
					'href'        => esc_url_raw( $url ),
					'crossorigin' => 'anonymous',
				);
			}
		}

		if ( 'dns-prefetch' === $relation_type ) {
			foreach ( apply_filters( 'rx_dns_prefetch_urls', self::$dns_prefetch_urls ) as $url ) {
				$urls[] = esc_url_raw( $url );
			}
		}

		return array_unique( $urls, SORT_REGULAR );
	}

	/**
	 * Print preload links for selected handles.
	 */
	public static function print_script_preloads(): void {

		if ( ! self::is_safe_frontend_context() ) {
			return;
		}

		$preload_handles = apply_filters( 'rx_preload_script_handles', self::$preload_handles );

		if ( empty( $preload_handles ) || ! is_array( $preload_handles ) ) {
			return;
		}

		global $wp_scripts;

		if ( ! $wp_scripts instanceof WP_Scripts ) {
			return;
		}

		foreach ( $preload_handles as $handle ) {

			if ( empty( $wp_scripts->registered[ $handle ] ) ) {
				continue;
			}

			$script = $wp_scripts->registered[ $handle ];
			$src    = self::normalize_script_src( $script->src );

			if ( empty( $src ) ) {
				continue;
			}

			printf(
				'<link rel="preload" href="%1$s" as="script" crossorigin="anonymous">' . "\n",
				esc_url( $src )
			);
		}
	}

	/**
	 * Remove query strings from static script files.
	 */
	public static function maybe_remove_script_query_strings( string $src, string $handle ): string {

		$remove = apply_filters( 'rx_remove_script_query_strings', false, $handle, $src );

		if ( ! $remove ) {
			return $src;
		}

		if ( false === strpos( $src, '.js?' ) ) {
			return $src;
		}

		return remove_query_arg( array( 'ver', 'v', 'version' ), $src );
	}

	/**
	 * Add module attribute.
	 */
	private static function maybe_add_module_attribute( string $tag, string $handle ): string {

		$module_handles = apply_filters( 'rx_module_script_handles', self::$module_handles );

		if ( in_array( $handle, $module_handles, true ) ) {
			$tag = self::replace_script_type( $tag, 'module' );
		}

		return $tag;
	}

	/**
	 * Add nomodule attribute.
	 */
	private static function maybe_add_nomodule_attribute( string $tag, string $handle ): string {

		$nomodule_handles = apply_filters( 'rx_nomodule_script_handles', self::$nomodule_handles );

		if ( in_array( $handle, $nomodule_handles, true ) ) {
			$tag = self::add_attribute_to_script_tag( $tag, 'nomodule', 'nomodule' );
		}

		return $tag;
	}

	/**
	 * Add fetchpriority attribute.
	 */
	private static function maybe_add_fetchpriority_attribute( string $tag, string $handle, string $src ): string {

		$high_priority_handles = apply_filters( 'rx_high_priority_script_handles', array() );
		$low_priority_handles  = apply_filters( 'rx_low_priority_script_handles', array() );

		if ( in_array( $handle, $high_priority_handles, true ) ) {
			return self::add_attribute_to_script_tag( $tag, 'fetchpriority', 'high' );
		}

		if ( in_array( $handle, $low_priority_handles, true ) ) {
			return self::add_attribute_to_script_tag( $tag, 'fetchpriority', 'low' );
		}

		return $tag;
	}

	/**
	 * Add attribute to script tag safely.
	 */
	private static function add_attribute_to_script_tag( string $tag, string $attribute, string $value ): string {

		if ( self::has_attribute( $tag, $attribute ) ) {
			return $tag;
		}

		return str_replace(
			'<script ',
			sprintf( '<script %s="%s" ', esc_attr( $attribute ), esc_attr( $value ) ),
			$tag
		);
	}

	/**
	 * Replace script type.
	 */
	private static function replace_script_type( string $tag, string $type ): string {

		if ( preg_match( '/type=[\'"][^\'"]+[\'"]/', $tag ) ) {
			return preg_replace(
				'/type=[\'"][^\'"]+[\'"]/',
				'type="' . esc_attr( $type ) . '"',
				$tag
			);
		}

		return str_replace(
			'<script ',
			'<script type="' . esc_attr( $type ) . '" ',
			$tag
		);
	}

	/**
	 * Check attribute exists.
	 */
	private static function has_attribute( string $tag, string $attribute ): bool {
		return (bool) preg_match( '/\s' . preg_quote( $attribute, '/' ) . '(\s|=|>)/i', $tag );
	}

	/**
	 * Normalize script source to full URL.
	 */
	private static function normalize_script_src( string $src ): string {

		if ( empty( $src ) ) {
			return '';
		}

		if ( 0 === strpos( $src, '//' ) ) {
			return is_ssl() ? 'https:' . $src : 'http:' . $src;
		}

		if ( 0 === strpos( $src, '/' ) ) {
			return home_url( $src );
		}

		return $src;
	}

	/**
	 * Is local theme/plugin script?
	 */
	private static function is_local_theme_or_plugin_script( string $src ): bool {

		$site_url    = home_url();
		$content_url = content_url();

		if ( false !== strpos( $src, $site_url ) || false !== strpos( $src, $content_url ) ) {
			if (
				false !== strpos( $src, '/wp-content/themes/' ) ||
				false !== strpos( $src, '/wp-content/plugins/' ) ||
				false !== strpos( $src, '/wp-content/uploads/' )
			) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Detect common safe CDN scripts.
	 */
	private static function is_common_safe_cdn_script( string $src ): bool {

		$safe_cdn_keywords = apply_filters(
			'rx_safe_cdn_script_keywords',
			array(
				'cdn.jsdelivr.net',
				'unpkg.com',
				'cdnjs.cloudflare.com',
				'static.cloudflareinsights.com',
			)
		);

		foreach ( $safe_cdn_keywords as $keyword ) {
			if ( false !== stripos( $src, $keyword ) ) {
				return true;
			}
		}

		return false;
	}
}

RX_Theme_Defer_Scripts::init();

endif;

Optional: add custom controls in functions.php

Use these only when needed.

1. Force defer specific handles

add_filter( 'rx_defer_script_handles', function( $handles ) {
	$handles[] = 'rx-main-js';
	$handles[] = 'rx-theme-js';
	$handles[] = 'rx-navigation-js';
	return $handles;
} );

2. Force async specific handles

add_filter( 'rx_async_script_handles', function( $handles ) {
	$handles[] = 'gtag';
	$handles[] = 'google-tag-manager';
	return $handles;
} );

3. Never defer important handles

add_filter( 'rx_never_defer_script_handles', function( $handles ) {
	$handles[] = 'my-critical-header-script';
	$handles[] = 'my-slider-before-load';
	return $handles;
} );

4. Preload important scripts

add_filter( 'rx_preload_script_handles', function( $handles ) {
	$handles[] = 'rx-main-js';
	return $handles;
} );

5. Delay third-party scripts after user interaction

add_filter( 'rx_auto_delay_third_party_scripts', '__return_true' );

Use this carefully. It can improve PageSpeed but may delay analytics, ads, chat widgets, tracking pixels, and other third-party scripts.

6. Remove query strings from JS files

add_filter( 'rx_remove_script_query_strings', '__return_true' );

7. Move selected scripts to footer

add_filter( 'rx_footer_script_handles', function( $handles ) {
	$handles[] = 'rx-main-js';
	$handles[] = 'rx-extra-js';
	return $handles;
} );

Important safe rule

Do not defer these unless you test carefully:

jquery
jquery-core
jquery-migrate
wp-polyfill
wp-element
wp-i18n
comment-reply
WooCommerce checkout/cart scripts
reCAPTCHA
Stripe
PayPal
Elementor editor scripts
Customizer scripts

For your RX theme, the safest strategy is:

Critical header script = normal
Theme scripts = defer
Analytics / independent third-party = async
Heavy third-party scripts = delay after interaction
jQuery-dependent old scripts = keep normal unless tested

Leave a Reply

Your email address will not be published. Required fields are marked *