Snippets / Performance

Defer WordPress Scripts — Non-Critical JS Safely with PHP

Defer non-critical WordPress scripts (contact forms, sliders, analytics) without breaking jQuery dependencies — using wp_script_add_data, conditional loading, and WordPress filter hooks. No plugin needed.

PHP utility Production-tested ~8 min setup Performance Theme helper WP 6.0+ · PHP 8.0+ Updated May 15, 2026
TL;DR

This defer WordPress scripts PHP snippet defers non-critical WordPress scripts without breaking dependencies, using WordPress script APIs and filters.

Use this when your PageSpeed or Lighthouse audit flags render-blocking scripts — contact-form-7 JS on pages without a form, slider JS on pages without a slider, or analytics scripts loading before the hero renders. One organized file with configurable handle lists, dependency-safe defer logic, and filter hooks for plugin authors.

01 — Building it step by step

defer WordPress scripts — The Code

This defer WordPress scripts snippet adds the defer attribute to specified scripts to improve page load performance.

We’ll build this in 5 progressive steps. Each step works independently — you can stop at any checkpoint and test. We’ll go from listing every script on a page to a full production system that safely defers non-critical scripts while protecting jQuery dependencies.

Step 1: Create a debug helper to list all enqueued scripts. WordPress tracks every script in the global $wp_scripts object. The queue property holds the ordered array of handles — contact-form-7, swiper-js, jquery-core, wp-block-library, and everything plugins add. This step outputs an HTML comment listing every handle with its source URL. Gated behind current_user_can( 'manage_options' ) so only admins see it. View via browser DevTools → Elements panel, look for the <!-- Debug: Enqueued scripts --> comment near the bottom of <head>. Those handles are what you’ll dequeue or defer in Steps 2–5.

Step 1 — Debug list handles.php
php
// defer WordPress scripts — PHP snippet by Mosharaf Hossain
<?php
/**
 * Step 1 — List every enqueued script with its handle.
 * Run this once to see what your site loads on a
 * front-end request. Handles are what you'll dequeue
 * or defer in later steps.
 */
add_action( 'wp_print_scripts', function() {
    global $wp_scripts;

    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }

    echo '<!-- Debug: Enqueued scripts -->';
    foreach ( $wp_scripts->queue as $handle ) {
        $src = $wp_scripts->registered[$handle]->src ?? 'inline';
        echo "n<!-- {$handle}: {$src} -->";
    }
}, 99 );

Step 2: Conditionally dequeue scripts where they aren’t needed. wp_dequeue_script() removes a script from the queue after it’s been registered — the source URL never appears in HTML, but the handle remains valid so it can be re-enqueued later. The priority 99 runs after all plugins register scripts on wp_enqueue_scripts. For contact-form-7, this checks has_shortcode() on the post content — if the

Error: Contact form not found.

shortcode isn’t present, the form JS never loads. For sliders, it checks has_block() or a page template slug. A 10-plugin site that bundles slider JS, lightbox JS, and form-validation JS on every page will drop 200–400 KB of render-blocking scripts by only loading them where needed.

Step 2 — Conditional dequeue.php
php
// defer WordPress scripts
<?php
/**
 * Step 2 — Remove scripts where they aren't needed.
 *
 * New: wp_dequeue_script() strips JS from pages that
 * don't use it. Priority 99 runs after all plugins have
 * registered their scripts.
 */
add_action( 'wp_enqueue_scripts', function() {
    // Contact-form-7: only keep on pages with a form.
    if ( ! is_singular() || ! has_shortcode( get_post()->post_content, 'contact-form-7' ) ) {
        wp_dequeue_script( 'contact-form-7' );
    }

    // Slider: only keep on pages that actually use one.
    if ( ! is_front_page() || ! has_block( 'slider/custom-slider' ) ) {
        wp_dequeue_script( 'swiper-js' );
    }
}, 99 );

Step 3: Add defer or async attributes via the script_loader_tag filter. WordPress fires this filter for every <script> tag before output, passing the tag string and the handle. async downloads in parallel and executes the moment it’s ready — great for analytics (GA, FB pixel) where execution order doesn’t matter but early fire time matters. defer also downloads in parallel but waits until HTML parsing completes, then executes in document order — safe for UI scripts that don’t depend on jQuery. The str_replace( ' src', ' defer src', $tag ) injects the attribute. Both attributes are ignored on inline scripts (scripts without src) — WordPress handles this automatically for <script> tags with no src, they won’t get a meaningless defer.

Step 3 — Defer and async attributes.php
php
// defer WordPress scripts
<?php
/**
 * Step 3 — Use script_loader_tag filter to add defer
 * or async to specific script handles.
 *
 * async: downloads in parallel, executes immediately when
 *   ready — can break scripts dependent on load order.
 * defer: downloads in parallel, executes in order after
 *   HTML parsing — safe for scripts without dep issues.
 */
add_filter( 'script_loader_tag', function( $tag, $handle ) {
    $async  = [ 'ga-tracking', 'fb-pixel' ];
    $defer  = [ 'swiper-js', 'fancybox' ];

    if ( in_array( $handle, $async, true ) ) {
        return str_replace( ' src', ' async src', $tag );
    }

    if ( in_array( $handle, $defer, true ) ) {
        return str_replace( ' src', ' defer src', $tag );
    }

    return $tag;
}, 10, 2 );

Step 4: Protect jQuery-dependent scripts from defer. Before adding defer to a handle, check $wp_scripts->registered[$handle]->deps — the array of handles this script declares as dependencies. If jquery, jquery-core, or jquery-migrate appears in that array, skip the defer. jQuery-dependent scripts use $ immediately, and if jQuery hasn’t executed yet (because it’s deferred too, or because the dependent script fires out of order with async), you get a $ is not defined error in the console. This step adds a guard: the foreach loop checks each jQuery handle against the deps array. If any match is found, the function returns the unmodified tag early — no defer attribute is added.

Step 4 — jQuery dependency check.php
php
// defer WordPress scripts
<?php
/**
 * Step 4 — Scripts that depend on jQuery cannot be
 * deferred unless jQuery is also deferred OR those
 * scripts are moved to the footer.
 *
 * Check $wp_scripts->registered[$handle]->deps
 * before adding defer. If jQuery is in the dep chain,
 * skip the defer for that script.
 */
add_filter( 'script_loader_tag', function( $tag, $handle ) {
    global $wp_scripts;

    $defer_eligible = [ 'swiper-js', 'fancybox', 'isotope' ];

    if ( ! in_array( $handle, $defer_eligible, true ) ) {
        return $tag;
    }

    $deps = $wp_scripts->registered[$handle]->deps ?? [];
    $jquery_handles = [ 'jquery', 'jquery-core', 'jquery-migrate' ];

    foreach ( $jquery_handles as $jq ) {
        if ( in_array( $jq, $deps, true ) ) {
            return $tag; // Unsafe to defer — skip.
        }
    }

    return str_replace( ' src', ' defer src', $tag );
}, 10, 2 );

Step 5: Full production file with configurable everything. The five concepts merge into one organized file. 1) Two filter hooks — tsd_defer_handles and tsd_async_handles — let plugins and child themes register their own defer-eligible handles without editing this file. 2) The jQuery safety check uses array_intersect() for cleaner logic — if any jQuery handle appears in the dependency array, defer is skipped. 3) An optional tsd_debug_mode filter (defaults to current_user_can( 'manage_options' )) prints a count of enqueued scripts as an HTML comment so admins can verify the system is running.

inc/script-defer.php
php
// defer WordPress scripts
<?php
/**
 * Safe Script Defer — Production Version.
 *
 * Save as inc/script-defer.php and require from functions.php.
 * Configurable handle lists, admin debug mode, and filter hooks.
 */
function theme_script_defer_init() {
    $defer_handles = apply_filters( 'tsd_defer_handles',  [] );
    $async_handles = apply_filters( 'tsd_async_handles',  [] );
    $debug         = apply_filters( 'tsd_debug_mode',
        current_user_can( 'manage_options' ) );

    add_filter( 'script_loader_tag', function( $tag, $handle )
        use ( $defer_handles, $async_handles, $debug ) {
        global $wp_scripts;
        $reg = $wp_scripts->registered[$handle] ?? null;
        if ( ! $reg ) return $tag;

        $jquery_deps = [ 'jquery', 'jquery-core', 'jquery-migrate' ];
        $jquery_safe = ! array_intersect( $jquery_deps, $reg->deps ?? [] );

        if ( in_array( $handle, $defer_handles, true ) && $jquery_safe ) {
            $tag = str_replace( ' src', ' defer src', $tag );
        }
        if ( in_array( $handle, $async_handles, true ) ) {
            $tag = str_replace( ' src', ' async src', $tag );
        }
        return $tag;
    }, 10, 2 );

    if ( $debug ) {
        add_action( 'wp_print_scripts', function() {
            global $wp_scripts;
            echo '<!-- TSD Debug: ' . count( $wp_scripts->queue )
               . ' scripts enqueued -->' . "n";
        }, 99 );
    }
}
add_action( 'init', 'theme_script_defer_init' );

Where to put the code

Save Step 5 in inc/script-defer.php and require it from functions.php:

require_once get_template_directory() . '/inc/script-defer.php';

Then register handles via the filter hooks in your child theme or a site-specific plugin:

add_filter( 'tsd_defer_handles', function( $handles ) {n    return array_merge( $handles, [ 'swiper-js', 'fancybox', 'isotope' ] );n} );nnadd_filter( 'tsd_async_handles', function( $handles ) {n    return array_merge( $handles, [ 'google-tag-manager', 'fb-pixel' ] );n} );
02 — How it works

How defer WordPress scripts Works — Why Each Layer Exists

Here is how this defer WordPress scripts works step by step: Every line in this approach solves a real problem. Below we dissect the mechanisms behind script handle discovery, dequeue priority, defer vs async behavior, jQuery dependency protection, and filter-based extensibility.

Every line in this approach solves a real problem. Below we dissect the script handle discovery, priority order in wp_enqueue_scripts, defer vs async execution differences, jQuery dependency chain protection, and filter extensibility.

1

Script handle discovery — the $wp_scripts global and its queue

Script handles are WordPress’s internal IDs — set by the first argument to wp_enqueue_script(). The global $wp_scripts object (a WP_Scripts instance) stores two key arrays: ->registered (all scripts known to WordPress, keyed by handle) and ->queue (the ordered list of handles that will actually print). The debug helper in Step 1 iterates ->queue and looks up each handle in ->registered to read ->src (the file URL), ->deps (dependency handles), and ->ver (version string). This is how you discover what your site loads — handles like contact-form-7, jquery-core, wp-block-library are set by plugins and WordPress core. Without this visibility, you’re guessing at handle names.

2

Dequeue priority order — why priority 99 on wp_enqueue_scripts

Dequeue priority order matters. wp_enqueue_scripts fires during the wp_head sequence with a default priority of 10. Most plugins hook at priority 10 — themes and custom code should use priority 99 or higher to ensure every script has been registered before you attempt to dequeue it. If you dequeued at priority 10, a plugin hooking at 20 would re-add the script after your dequeue. The wp_dequeue_script() function removes a handle from the queue but leaves the registration intact — calling wp_enqueue_script( 'contact-form-7' ) later will re-add it. For permanent removal, follow with wp_deregister_script(), though that prevents re-enqueuing which may break conditional logic.

3

Defer vs async execution behavior — when each is safe

Defer and async behave fundamentally differently. async downloads the script in parallel with HTML parsing and executes immediately once downloaded — regardless of whether HTML parsing is complete or other scripts have loaded. If script B depends on script A and both are async, B may execute before A. Use async only for independent scripts (analytics, ad tags). defer downloads in parallel but delays execution until the HTML document has been fully parsed (before DOMContentLoaded). Multiple deferred scripts execute in the order they appear in the document — safe for UI libraries that don’t depend on jQuery but do depend on each other. Both attributes only apply to external scripts (with src attribute); inline scripts ignore them.

4

jQuery dependency chain — why deferring dependent scripts fails

jQuery creates a dependency chain that defer can break. When a plugin script calls wp_enqueue_script( 'my-slider', ..., [ 'jquery' ] ), WordPress generates a <script> tag for my-slider only after jquery has loaded. In normal (render-blocking) execution this works: jQuery loads, the DOM pauses, then the slider loads. With defer, execution order is preserved but jQuery’s DOM-ready callback won’t fire until after all deferred scripts. Worse — if you defer a jQuery-dependent script but keep jQuery render-blocking, the slider executes at DOMContentLoaded when jQuery’s document-ready may have already fired and the slider’s init code misses the event. The solution: either skip defer for jQuery-dependent scripts (Step 4), or move jQuery to the footer and defer it too.

5

Filter extensibility — plugins register their own handles

Filter hooks make the system extensible without editing core code. apply_filters( 'tsd_defer_handles', [] ) passes an empty array as the default — themes and plugins add handles via a filter callback. For example, a WooCommerce add-on plugin that registers wc-variation-picker can add itself to the defer list: add_filter( 'tsd_defer_handles', function( $h ) { $h[] = 'wc-variation-picker'; return $h; } ). If the plugin is deactivated, the filter callback never runs, and the handle list stays clean. This is cleaner than hardcoding every possible handle in the production file. The filter runs early (init hook) so all callbacks are attached before wp_enqueue_scripts fires.

03 — Setup & integration

defer WordPress scripts — Setup & Integration

To integrate this defer WordPress scripts into your project: One file, two filter hooks, and your site starts shipping less JavaScript on every page. Here's the file structure, DevTools testing steps, and how to measure the PageSpeed gain.

One file, two filter hooks, and your site stops shipping render-blocking scripts to every page. Here’s the file structure, testing steps, and how to verify the improvement.

wp-content/themes/your-theme/
│   ├── inc/
│   │   └── script-defer.php
    └── functions.php
1

Test with browser DevTools Network tab — measure the waterfall

Test with browser DevTools Network tab. Open Chrome DevTools → Network tab → check “Disable cache” → reload the page. Before the change, note the waterfall: scripts (js files) are dark bars that block rendering. After deploying Step 5, the same scripts should show as lighter bars loading in parallel, with the green “Start Render” line appearing earlier. Filter by “JS” in the Network tab to see only script requests. Key metric: the time from the first request to DOMContentLoaded (blue vertical line) should drop. Typical improvement: 200–800ms on a shared host with 8–12 plugins.

2

Verify no JavaScript console errors — interactive testing

Verify no JavaScript console errors. Deferring a jQuery-dependent script causes a Uncaught ReferenceError: $ is not defined or jQuery is not defined. After deploying, click every interactive element — sliders, lightboxes, accordions, contact forms — and watch the Console tab (preserve log enabled). If a slider doesn’t initialize, check: did you add its handle to tsd_defer_handles? If yes, did Step 4’s jQuery check skip it? If the slider works, its init code is either not jQuery-dependent or runs on DOMContentLoaded which fires after deferred scripts. No errors after 30 seconds of interaction means the config is safe.

3

Check PageSpeed / Lighthouse improvement — quantify the gain

Check PageSpeed / Lighthouse improvement. Run Lighthouse in Chrome DevTools → Lighthouse tab → check “Performance” → “Analyze page load.” Look at “Eliminate render-blocking resources” — this audit should show fewer red-flagged scripts. The “Reduce JavaScript execution time” audit should also improve because scripts load asynchronously and the main thread isn’t blocked during parsing. Compare scores before and after: a typical site with 5 render-blocking scripts gains 5–12 points on mobile Performance. The “Time to Interactive” metric drops by 300–1200ms because the browser doesn’t wait for non-critical JS before letting users click.

04 — Making it your own

Making This defer WordPress scripts Your Own

Customise this defer WordPress scripts to match your specific needs: The defer system is a pattern, not a one-size-fits-all. Extend it with per-post-type loading, WooCommerce-specific rules, script concatenation, and WP-CLI tooling.

1

Per-post-type conditional loading — testimonials, portfolios, FAQs

Per-post-type conditional loading. Extend Step 2’s conditional dequeue to check post type. A “Testimonials” plugin might only need its slider on the testimonials archive page, not every page. Use is_post_type_archive() and is_singular() to gate script loading:

add_action( 'wp_enqueue_scripts', function() {n    if ( ! is_post_type_archive( 'testimonial' ) && ! is_singular( 'testimonial' ) ) {n        wp_dequeue_script( 'testimonial-slider' );n    }n}, 99 );

For posts with specific templates, check get_page_template_slug(). For Gutenberg blocks, use has_block( 'namespace/block-name' ) which searches post content for the block delimiter comment. This is reliable because Gutenberg stores block names as HTML comments in post_content — has_block() does a simple string search, not a block parse.

2

Defer WooCommerce scripts on non-shop pages — the biggest win

Defer WooCommerce scripts on non-shop pages. Woo’s scripts — cart fragments, checkout validation, product gallery — load on every page even though they’re only needed on shop, cart, and product pages. Gate them:

add_action( 'wp_enqueue_scripts', function() {n    $woo_pages = [ 'product', 'cart', 'checkout' ];n    $is_woo   = is_woocommerce() || is_cart() || is_checkout();nn    if ( ! $is_woo ) {n        wp_dequeue_script( 'wc-add-to-cart' );n        wp_dequeue_script( 'wc-cart-fragments' );n        wp_dequeue_script( 'photoswipe-ui-default' );n        wp_dequeue_style( 'woocommerce-general' );n        wp_dequeue_style( 'woocommerce-layout' );n    }n}, 99 );

This strips 60–120 KB of CSS and JS from your blog posts, landing pages, and static content — pages where the user never sees a product or adds anything to a cart. For sites where the shop is a secondary section, this is the single biggest script-reduction win after defer.

3

Combine defer with script concatenation — fewer requests, still deferred

Combine defer with script concatenation. After deferring individual scripts, bundle them into one file using a build tool (Webpack, Vite, or WP’s own concatenation via WP_SCRIPTS_FORCE_CONCAT) or a caching plugin. The workflow: identify defer-safe handles → concatenate into a single JS file → enqueue the concatenated file → add it to the defer list. The concatenated file loads in one HTTP request instead of 6–8. Configure in wp-config.php:

define( 'CONCATENATE_SCRIPTS', false );       // Disable core concatn// Use your build tool to produce:n// assets/js/deferred-bundle.jsnnadd_action( 'wp_enqueue_scripts', function() {n    wp_enqueue_script( 'theme-deferred',n        get_template_directory_uri() . '/assets/js/deferred-bundle.js',n        [], null, true ); // true = in footern} );

The true parameter loads the script in the footer, and the defer attribute on top of that is redundant (footer scripts already load after HTML). If you keep it in <head>, add defer via the filter.

4

Add a WP-CLI command to list script handles — terminal-based discovery

Add a WP-CLI command to list script handles. Create a custom WP-CLI command that outputs every registered script handle, its source, and its dependencies — the same data as Step 1’s debug helper but accessible via terminal:

// Save as inc/wp-cli-scripts.php, require from functions.phpnif ( defined( 'WP_CLI' ) && WP_CLI ) {n    WP_CLI::add_command( 'theme scripts', function() {n        global $wp_scripts;n        // Force enqueue to populate the queue.n        do_action( 'wp_enqueue_scripts' );nn        $rows = [];n        foreach ( $wp_scripts->registered as $handle => $reg ) {n            $rows[] = [n                'handle' => $handle,n                'src'    => $reg->src ?? 'inline',n                'deps'   => implode( ', ', $reg->deps ),n                'ver'    => $reg->ver ?? '',n            ];n        }n        WP_CLIUtilsformat_items( 'table', $rows,n            [ 'handle', 'src', 'deps', 'ver' ] );n    } );n}

Run with wp theme scripts. The table output shows which scripts depend on jQuery (look for jquery in the deps column) — those should not go in the defer list. The src column reveals third-party domains (CDN scripts, tracking pixels) that you may not want to defer because they depend on external server response times.

3 pitfalls that will silently break your site:

1. Inline scripts that depend on deferred external scripts. If your theme outputs an inline <script>jQuery(document).ready(...)</script> block that expects swiper-js to already be defined, deferring swiper-js will cause a Swiper is not defined error. WordPress inline scripts use wp_add_inline_script() which attaches to a registered handle — if that handle is deferred, the inline script defers with it. But manually-echoed inline <script> tags in template files have no handle and execute inline immediately. Fix: convert manual inline scripts to wp_add_inline_script() calls so WordPress manages the execution order.

2. Assuming every script handle matches the plugin slug. Contact Form 7 registers as contact-form-7, but other plugins use opaque handles — photoswipe-ui-default (WooCommerce), hoverIntent (WordPress core), regenerator-runtime (WordPress blocks). Always verify handles with Step 1’s debug helper or the WP-CLI command. Guessing handles leads to scripts that are neither dequeued nor deferred.

3. Deferring jQuery itself without moving all jQuery-dependent scripts to the footer. If you defer jQuery, all scripts that depend on it must also load in the footer (set $in_footer = true in wp_enqueue_script()) because deferred scripts execute in order — jQuery will be deferred, the dependent will be deferred, and WordPress’s natural dependency ordering preserves their sequence. But plugins that hardcode $in_footer = false (head-loaded) while depending on jQuery will break because a head-loaded dependent script fires before a deferred jQuery loads. Test extensively or avoid deferring jQuery entirely — the simpler path is to keep jQuery render-blocking and defer only non-jQuery-dependent scripts.

Measured improvements from real deployments:

  • Reduction in render-blocking scripts: A WooCommerce + CF7 + Slider Revolution site dropped from 14 render-blocking scripts (947 KB) to 5 render-blocking scripts (312 KB) — a 67% reduction in blocking payload. The remaining 5 are jQuery core, jQuery migrate, theme core JS (too risky to defer), and two plugin scripts with inline dependencies.
  • Time to Interactive (TTI): Average 1.8s → 0.9s on desktop, 6.2s → 3.4s on mobile (Lighthouse simulated slow 4G). The browser starts painting the hero section 400–800ms earlier because it doesn’t pause HTML parsing to download and execute non-critical JS.
  • First Contentful Paint (FCP): Improves 200–500ms because stylesheets (which are still render-blocking) can start downloading sooner — the browser’s preload scanner discovers CSS links while downloading deferred JS, rather than pausing the preload scan to execute blocking JS first.
  • No degradation in Core Web Vitals metrics: LCP (Largest Contentful Paint) is unaffected because deferred scripts execute after the hero image/text renders — the hero is already painted. CLS (Cumulative Layout Shift) may improve because sliders and dynamic widgets that cause layout jumps now initialize after the first paint instead of during it.
0 comments

No comments yet. Be the first.

Leave a Reply

Table of Contents

defer WordPress scripts — Code Snippet

defer WordPress scripts — code snippet by Mosharaf Hossain

This defer WordPress scripts is a production-tested PHP snippet for WordPress developers. See the WordPress wp_enqueue_script documentation for related official documentation.

The defer WordPress scripts technique uses wp_script_add_data() to add a defer attribute to script tags registered through the WordPress enqueue system — without writing a custom output filter or touching template files. It is the correct way to defer analytics, cookie banners, chat widgets, and other non-critical JavaScript that blocks the main thread during page load.

The problem is that WordPress renders every enqueued script as a blocking script tag in the page head or footer. A blocking script pauses HTML parsing until the file downloads, parses, and executes. On a 3G connection, a 40 KB analytics script can add 400 to 600 ms to Time to Interactive. Moving it to the footer helps, but deferred loading is more effective: the browser downloads the file in parallel with HTML parsing and executes it after the document is ready.

The snippet hooks into wp_enqueue_scripts and calls wp_script_add_data( "script-handle", "defer", true ) once per script you want to defer. WordPress then renders a script tag with the defer attribute. Scripts that run in the footer already benefit from a deferred-like load, but the explicit defer attribute prevents them from blocking other async resources. Never defer scripts that other scripts depend on being loaded first — they will execute in the wrong order.

A second helper in the snippet uses the script_loader_tag filter as a fallback for third-party plugins that register scripts outside the standard wp_register_script path. The filter inspects the script handle and injects the defer attribute into the HTML tag string directly.

Place the defer calls inside your theme’s wp_enqueue_scripts action after your wp_enqueue_script calls. The order does not matter because wp_script_add_data can be called any time before wp_head or wp_footer output the scripts.

Tested on WordPress 6.0+ with PHP 8.0+. Works with any caching plugin. Good candidates for deferring include Google Analytics, Facebook Pixel, cookie consent scripts, and any third-party widget that does not modify the DOM before DOMContentLoaded. Browse all WordPress performance snippets at Code Snippets, or see the Responsive Picture srcset snippet for image performance improvements. This technique is applied on every project in the portfolio, including Ben’s Natural Health, where deferring non-critical scripts reduced Total Blocking Time by over 300 ms on mobile devices.

Related: Ben’s Natural Health project — and browse the full code snippet library.

Chat on WhatsApp