14+ years building on WordPress / Replies in under 5 hours
Development 12 min read · Updated July 2026

Properly Enqueuing Scripts and Styles in WordPress (and Avoiding the Performance Pitfalls)

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Properly Enqueuing Scripts and Styles in WordPress (and Avoiding the Performance Pitfalls)
TL;DR

Adding script or link tags directly into theme files instead of using WordPress's enqueue system causes version conflicts, missing dependencies, and wrong load order. Register and load all assets through wp_enqueue_script() and wp_enqueue_style() on the wp_enqueue_scripts hook, declare dependencies in $deps, use filemtime() for cache busting, set $in_footer to true (or the $args strategy array in WP 6.3+), pass PHP data to JavaScript with wp_localize_script(), and restrict assets to the pages that need them with conditional tags.

Every WordPress developer hits the same wall early on: scripts load in the wrong order, jQuery gets duplicated, or a plugin’s CSS bleeds into every page of a site. All of those problems trace back to the same root cause — bypassing WordPress’s built-in asset management system.

WordPress ships with a dependency-aware queue for scripts and stylesheets. Use it correctly and the platform handles load order, version-based cache busting, and conditional loading automatically. Skip it and you’re managing those concerns manually, one conflict at a time.

This guide covers everything: wp_enqueue_script(), wp_enqueue_style(), registering versus enqueueing, passing PHP data to JavaScript with wp_localize_script(), conditional loading, dequeuing plugin assets, and loading assets in the admin and block editor.

Why WordPress Has an Asset Queue

When two plugins both output a raw <script src="jquery.min.js"> tag, the browser loads jQuery twice — different versions, different cache entries, potential conflicts. WordPress’s enqueue system solves this by treating every script and stylesheet as a named entry in a dependency graph.

  • Deduplication: A handle registered once loads once, even if ten plugins request it.
  • Dependency ordering: Declare that your script depends on jquery and WordPress ensures jQuery is in the DOM before your code runs.
  • Cache busting: A version string becomes a query parameter (?ver=1.4.2), so browsers invalidate the cache when you ship a new file.
  • Conditional removal: Any script in the queue can be dequeued by a later hook before the page renders — impossible with hardcoded tags.

The correct action hook for frontend assets is wp_enqueue_scripts (note the plural):

function my_theme_assets() {
    // All wp_enqueue_script() and wp_enqueue_style() calls go here
}
add_action( 'wp_enqueue_scripts', 'my_theme_assets' );

wp_enqueue_script(): Every Argument Explained

wp_enqueue_script( $handle, $src, $deps, $ver, $in_footer );

$handle — the unique name

A lowercase slug that identifies this script across the whole WordPress installation. Always prefix it (mytheme-, myplugin-) to avoid colliding with core or third-party handles. This handle is what you pass to wp_dequeue_script() or reference in a dependency array.

$src — the URL

Use get_template_directory_uri() for theme files and plugin_dir_url( __FILE__ ) for plugin files. Never hardcode a domain.

$deps — the dependency array

WordPress resolves this before printing any <script> tags. If your slider needs jQuery, write array( 'jquery' ); WordPress guarantees jQuery loads first. If jQuery isn’t already queued, WordPress queues it automatically.

$ver — version string for cache busting

Use filemtime() during development (busts cache on every file save) and a pinned semantic version in production:

// Development: auto-bust on every file change
$ver = filemtime( get_template_directory() . '/js/main.js' );

// Production: pinned version from a constant
$ver = MY_THEME_VERSION;

$in_footer — load position

Pass true to print the tag just before </body>. This is the single easiest performance win in WordPress asset management: the browser can paint the page before it parses your JavaScript. Only pass false (the default) when a script genuinely must run before the first element renders.

WP 6.3+: defer and async via the $args array

From WordPress 6.3 onward, the fifth argument can be an array instead of a boolean. This unlocks native defer and async attributes without monkey-patching script_loader_tag:

wp_enqueue_script(
    'mytheme-main',
    get_template_directory_uri() . '/js/main.js',
    array( 'jquery' ),
    MYTHEME_VERSION,
    array(
        'in_footer' => true,
        'strategy'  => 'defer',   // or 'async'
    )
);

Use strategy: 'defer' for scripts that depend on the DOM. Use strategy: 'async' only for fully independent scripts (analytics, ad tags) that have no dependencies in the queue.

wp_enqueue_style(): Loading Stylesheets

wp_enqueue_style( $handle, $src, $deps, $ver, $media );

The arguments map directly to the script equivalent, except the fifth parameter is $media (e.g. 'all', 'screen', 'print') rather than a footer flag. Stylesheets belong in the <head> — loading them late causes a Flash of Unstyled Content (FOUC).

function mytheme_styles() {
    wp_enqueue_style(
        'mytheme-main',
        get_template_directory_uri() . '/css/main.css',
        array(),
        filemtime( get_template_directory() . '/css/main.css' ),
        'all'
    );

    // Component stylesheet that depends on the base reset
    wp_enqueue_style(
        'mytheme-slider',
        get_template_directory_uri() . '/css/slider.css',
        array( 'mytheme-main' ),
        MYTHEME_VERSION,
        'all'
    );
}
add_action( 'wp_enqueue_scripts', 'mytheme_styles' );

Style dependencies work the same way as script dependencies. If your component stylesheet requires a base reset, add the reset’s handle to $deps and WordPress keeps them in order.

wp_register_script() vs wp_enqueue_script()

Registering adds a script to the dependency graph without printing a tag. Enqueueing both registers and marks it for output. The distinction matters when you want to register a library in a central location (say, your plugin bootstrap) but only load it on pages that actually need it:

// Register in the main plugin file — available everywhere, loaded nowhere yet
function myplugin_register_assets() {
    wp_register_script(
        'myplugin-chart',
        plugin_dir_url( __FILE__ ) . 'js/chart.min.js',
        array(),
        '4.4.2',
        true
    );
}
add_action( 'wp_enqueue_scripts', 'myplugin_register_assets' );

// Only enqueue on pages that render the shortcode
function myplugin_maybe_enqueue_chart() {
    if ( is_singular() && has_shortcode( get_post()->post_content, 'sales_chart' ) ) {
        wp_enqueue_script( 'myplugin-chart' );
    }
}
add_action( 'wp_enqueue_scripts', 'myplugin_maybe_enqueue_chart', 20 );

This pattern is common for JavaScript libraries that power optional features — register once, enqueue conditionally. The same wp_register_style() / wp_enqueue_style() pair exists for stylesheets.

Passing PHP Data to JavaScript

A JavaScript file served from disk cannot read PHP variables at runtime. The two approved ways to bridge the gap are wp_localize_script() for structured data and wp_add_inline_script() for raw JS.

wp_localize_script()

This function serializes a PHP array as a JavaScript object and prints it in a <script> block immediately before the target handle. It must be called after wp_enqueue_script():

function myplugin_enqueue() {
    wp_enqueue_script(
        'myplugin-main',
        plugin_dir_url( __FILE__ ) . 'js/main.js',
        array( 'jquery' ),
        MYPLUGIN_VERSION,
        true
    );

    wp_localize_script(
        'myplugin-main',
        'myPluginData',           // JS variable name
        array(
            'ajaxUrl' => admin_url( 'admin-ajax.php' ),
            'nonce'   => wp_create_nonce( 'myplugin_ajax' ),
            'postId'  => get_the_ID(),
        )
    );
}
add_action( 'wp_enqueue_scripts', 'myplugin_enqueue' );

In your JavaScript file, reference the object by the name you passed as the second argument (myPluginData above):

jQuery.ajax({
    url: myPluginData.ajaxUrl,
    data: {
        action: 'myplugin_action',
        nonce:  myPluginData.nonce,
        postId: myPluginData.postId,
    }
});

wp_add_inline_script()

When you need to output raw JavaScript rather than a structured object — for example, to set a configuration flag or call an initializer — use wp_add_inline_script():

wp_add_inline_script(
    'myplugin-main',
    'window.myPlugin = window.myPlugin || {}; myPlugin.debug = ' . ( WP_DEBUG ? 'true' : 'false' ) . ';',
    'before'
);

The third argument is 'before' or 'after' (default), controlling where the inline block appears relative to the script tag.

Conditional Loading for Real Performance Gains

Loading every asset on every page is the most common WordPress performance mistake. WordPress ships with a rich set of conditional tags — use them inside your enqueue callback to restrict each asset to the pages that actually need it:

function mytheme_conditional_assets() {

    // Slider only on the homepage
    if ( is_front_page() ) {
        wp_enqueue_style( 'mytheme-slider', get_template_directory_uri() . '/css/slider.css', array(), MYTHEME_VERSION );
        wp_enqueue_script( 'mytheme-slider', get_template_directory_uri() . '/js/slider.js', array( 'jquery' ), MYTHEME_VERSION, true );
    }

    // Comment reply script only on single posts with comments open
    if ( is_singular() && comments_open() ) {
        wp_enqueue_script( 'comment-reply' );
    }

    // Contact form assets only on the contact page
    if ( is_page( 'contact' ) ) {
        wp_enqueue_script( 'mytheme-contact', get_template_directory_uri() . '/js/contact.js', array(), MYTHEME_VERSION, true );
    }
}
add_action( 'wp_enqueue_scripts', 'mytheme_conditional_assets', 20 );

The priority argument on add_action (20 in the example above) ensures conditional checks run after all plugins have had a chance to register their assets at the default priority of 10.

For WooCommerce contexts, use is_woocommerce(), is_cart(), is_checkout(), and is_product() instead of their generic equivalents to avoid false matches on WooCommerce archive pages.

Dequeuing and Deregistering Plugin Assets

Plugins frequently load scripts and stylesheets on pages that don’t use them. Use wp_dequeue_script() or wp_dequeue_style() to remove an asset from the output queue without removing it from the registry (so it can still be depended on). Use wp_deregister_script() / wp_deregister_style() to remove it entirely:

function mytheme_cleanup_plugin_assets() {

    // Dequeue Contact Form 7 styles on every page except those with the form
    if ( ! is_page( array( 'contact', 'request-quote' ) ) ) {
        wp_dequeue_style( 'contact-form-7' );
        wp_dequeue_script( 'contact-form-7' );
    }

    // Swap the bundled jQuery for a CDN version (deregister required for src swap)
    wp_deregister_script( 'jquery' );
    wp_register_script( 'jquery', 'https://cdn.example.com/jquery-3.7.1.min.js', array(), '3.7.1', true );
}
add_action( 'wp_enqueue_scripts', 'mytheme_cleanup_plugin_assets', 20 );

The priority on add_action must be higher than the plugin’s own enqueue priority — plugins typically use the default of 10, so 20 or higher is usually safe. Check the plugin source if dequeue seems to have no effect; the plugin may be using a late priority itself.

To find a plugin’s handle, inspect the page source and note the id attribute on the <script> tag — WordPress appends -js to the handle, so id="contact-form-7-js" means the handle is contact-form-7.

Enqueueing Assets in the Admin and Block Editor

The wp_enqueue_scripts hook fires only on the front end. For the wp-admin dashboard, use admin_enqueue_scripts; for the Gutenberg block editor specifically, use enqueue_block_editor_assets.

Admin assets

function myplugin_admin_assets( $hook ) {
    // Only load on this plugin's settings page
    if ( 'settings_page_myplugin' !== $hook ) {
        return;
    }

    wp_enqueue_style(
        'myplugin-admin',
        plugin_dir_url( __FILE__ ) . 'css/admin.css',
        array(),
        MYPLUGIN_VERSION
    );

    wp_enqueue_script(
        'myplugin-admin-settings',
        plugin_dir_url( __FILE__ ) . 'js/admin-settings.js',
        array( 'jquery', 'wp-color-picker' ),
        MYPLUGIN_VERSION,
        true
    );
}
add_action( 'admin_enqueue_scripts', 'myplugin_admin_assets' );

The $hook parameter passed to the callback identifies the current admin page (e.g., post.php, toplevel_page_my-plugin). Use it to scope assets to the screens that need them — loading custom admin CSS on every wp-admin page slows the editor for no reason.

Block editor assets

function myplugin_block_editor_assets() {
    wp_enqueue_script(
        'myplugin-block-editor',
        plugin_dir_url( __FILE__ ) . 'js/block-editor.js',
        array( 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components' ),
        MYPLUGIN_VERSION,
        true
    );

    wp_enqueue_style(
        'myplugin-block-editor-style',
        plugin_dir_url( __FILE__ ) . 'css/editor.css',
        array( 'wp-edit-blocks' ),
        MYPLUGIN_VERSION
    );
}
add_action( 'enqueue_block_editor_assets', 'myplugin_block_editor_assets' );

enqueue_block_editor_assets fires inside the block editor context and is the correct hook for custom block scripts or editor-only stylesheets. For assets that should load in the editor and on the front end, use enqueue_block_assets instead.

A Complete Theme Asset Loader

Here is a production-ready theme enqueue setup combining everything above — cache busting, footer loading, inline data, and conditional assets:

define( 'MYTHEME_VERSION', '2.4.1' );

function mytheme_enqueue_assets() {
    $dir = get_template_directory();
    $uri = get_template_directory_uri();

    // Main stylesheet (always loads)
    wp_enqueue_style(
        'mytheme-main',
        $uri . '/css/main.css',
        array(),
        filemtime( $dir . '/css/main.css' )
    );

    // Main JS (deferred, depends on jQuery)
    wp_enqueue_script(
        'mytheme-main',
        $uri . '/js/main.js',
        array( 'jquery' ),
        filemtime( $dir . '/js/main.js' ),
        array( 'in_footer' => true, 'strategy' => 'defer' )
    );

    // Pass REST nonce and site URL to JS
    wp_localize_script(
        'mytheme-main',
        'mythemeData',
        array(
            'restUrl' => esc_url( rest_url() ),
            'nonce'   => wp_create_nonce( 'wp_rest' ),
        )
    );

    // Slider: front page only
    if ( is_front_page() ) {
        wp_enqueue_style( 'mytheme-slider', $uri . '/css/slider.css', array( 'mytheme-main' ), MYTHEME_VERSION );
        wp_enqueue_script( 'mytheme-slider', $uri . '/js/slider.js', array( 'jquery' ), MYTHEME_VERSION, true );
    }

    // Comment reply: single posts with open comments only
    if ( is_singular() && comments_open() ) {
        wp_enqueue_script( 'comment-reply' );
    }
}
add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_assets' );

Checklist: WordPress Asset Enqueueing

  1. All scripts and styles go through wp_enqueue_script() / wp_enqueue_style() — no hardcoded tags.
  2. Every handle is prefixed (mytheme-, myplugin-) to avoid collisions.
  3. Every URL uses get_template_directory_uri() (themes) or plugin_dir_url( __FILE__ ) (plugins).
  4. Dependencies are declared in $deps — never rely on page order.
  5. Scripts load in the footer ($in_footer = true) unless there is a specific reason not to.
  6. WP 6.3+: use the $args array form with strategy: 'defer' instead of script_loader_tag hacks.
  7. Version strings use filemtime() during development and a constant in production.
  8. PHP data travels to JavaScript via wp_localize_script() or wp_add_inline_script().
  9. Assets are restricted to the pages that need them using conditional tags.
  10. Plugin assets are removed with wp_dequeue_ at a higher hook priority (20+).

Need help auditing your site’s asset footprint or migrating a theme off hardcoded script tags? Get in touch — I can profile what’s loading, strip the bloat, and wire up conditional loading so each page only pays for what it uses.

For more on managing PHP dependencies in WordPress projects, see the guide on using Composer for WordPress dependencies. If you’re working with external third-party scripts, integrating external JavaScript in WordPress covers the patterns for safely pulling in CDN-hosted libraries.

Frequently asked questions

wp_register_script() adds a script to WordPress's internal registry so it is available as a dependency and can be enqueued later, but it does not schedule it for output. wp_enqueue_script() both registers (if not already registered) and marks the script for output on the current page. The register-then-conditionally-enqueue pattern is common for optional features: register the library once in a central location, then call wp_enqueue_script( 'my-handle' ) only on pages that actually render the feature that needs it.

The standard method is wp_localize_script(), which serializes a PHP associative array as a JavaScript object and prints a <script> block immediately before the target handle. Call it after wp_enqueue_script() and pass the handle, a JavaScript variable name, and the data array. In your JS file, read the object by the name you chose. For raw JavaScript (not an object literal) use wp_add_inline_script() instead — it appends or prepends an inline block to the target handle and does not wrap the output in an object assignment.

Browsers parse HTML top to bottom. A <script> tag in the <head> blocks rendering until the file is downloaded and executed — nothing on the page appears to the user until the script finishes. Moving scripts to the footer (setting $in_footer to true) lets the browser render all visible content first, then handle JavaScript. For WordPress 6.3+, you can go further and add strategy: 'defer' via the $args array, which tells the browser to parse the script in parallel with HTML and execute it after the document is ready, without blocking rendering at all.

From WordPress 6.3 onward, pass an array as the fifth argument to wp_enqueue_script() instead of a boolean: array( 'in_footer' => true, 'strategy' => 'defer' ) (or 'async'). WordPress will output the correct HTML attribute natively. Before 6.3, the only way was to filter script_loader_tag and add the attribute manually — a string-replacement hack that breaks if the handle is used in a concatenated file. Use the native API if your WordPress version supports it.

Hook into wp_enqueue_scripts at a priority higher than the plugin (20 is usually enough) and call wp_dequeue_script( 'handle' ) or wp_dequeue_style( 'handle' ). To find the handle, inspect the page source and look at the id attribute on the <script> tag — WordPress appends -js to the handle, so id="contact-form-7-js" means the handle is contact-form-7. If you want to swap the file entirely (for example, to replace a bundled jQuery with a CDN version), use wp_deregister_script() followed by wp_register_script() with the same handle pointing to your preferred source.

Yes. The hook and function are identical — the only difference is the URL helper. Themes use get_template_directory_uri(); plugins use plugin_dir_url( __FILE__ ) to get a URL relative to the current plugin file, or plugins_url( 'js/main.js', __FILE__ ) for a file path relative to the current file's directory. Never hardcode a domain or an absolute server path in a src URL — the helper functions ensure the URL is correct regardless of where WordPress is installed or whether HTTPS is active.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →