Dropping jQuery From Your WordPress Theme: A Migration Path
A tested migration path to remove jQuery from WordPress themes: audit dependents, drop jQuery Migrate first, rewrite the patterns, and know when to stop.
On a client site last spring we stripped jQuery and jQuery Migrate from a theme and saved 33KB over the wire after Brotli, plus about 90ms of script evaluation on a throttled Moto G class device. Real, measurable, and far less dramatic than the blog posts promised. The Lighthouse score moved four points.
That is the honest framing. If you want to remove jQuery from WordPress because you expect a performance miracle, you’ll be disappointed. The reason to do it is different: jQuery in a theme is usually the last thing forcing you to keep a build-free, pattern-free, untestable pile of $(document).ready blocks that nobody wants to touch. Dropping it is a code health project with a performance side effect.
Here’s the path we use, in the order we use it, including the two points where we abort and leave jQuery in place.
- The
jqueryhandle in WordPress is an empty meta-script whose dependencies arejquery-coreandjquery-migrate. Understanding that structure is what lets you drop Migrate first and deregister safely later without white-screening every plugin that declares a jQuery dependency. - Realistic saving on a typical theme: around 30 to 35KB compressed and 60 to 120ms of parse plus evaluate on mid-tier Android. Worth having, not worth breaking checkout for.
- Audit at runtime, not just with grep. A Proxy wrapper around
window.jQuerythat logs method names and stack frames finds the plugin calling.slideToggle()from a conditionally loaded script that your static search missed. - Never deregister jQuery in
wp-adminor the Customizer. Core admin screens, media modals and most plugin settings pages are built on it and will break silently. - On WooCommerce classic cart and checkout, or any site running a legacy page builder, stop. jQuery is a hard dependency there and no amount of shimming makes that safe.
What you actually win, measured
WordPress 6.8 ships jQuery 3.7.1. The minified core file is roughly 85KB uncompressed and lands near 30KB after Brotli. jQuery Migrate 3.4.1 adds another 10KB or so raw, around 4KB compressed, and it runs a patching pass at startup you are paying for on every single page load.
Both are enqueued in the <head> by default without defer, so on a slow connection they sit in the critical path. That’s where the real damage is. Two blocking requests plus evaluation before your first paint matters more than the kilobytes.
Measure before you start, or you have no argument when the client asks what they paid for. We use WebPageTest on a 4G profile with Moto G4 emulation, record Total Blocking Time and Largest Contentful Paint across five runs, then take the median. Chrome DevTools’ Coverage tab is worth opening too: on most themes it reports 80 to 95 percent of jQuery as unused bytes on a given page. Those bytes still have to be parsed, which makes it a useful slide for the stakeholder deck.

Kill jQuery Migrate before you touch jQuery
This is the free win and almost nobody does it first. jQuery Migrate in WordPress exists to keep code written against jQuery 1.x working on 3.x. It restores removed APIs like $.browser and jQuery.fn.size(), and it logs deprecation warnings to the console.
If your theme and plugins are current, you need none of it. Here’s the clean removal, which works because jquery is registered as an empty script with dependencies rather than a file:
addaction( 'wpdefaultscripts', function ( WPScripts $scripts ) {
if ( is_admin() ) {
return; // Admin and the Customizer keep Migrate. Do not negotiate on this.
}
$jquery = $scripts->registered['jquery'] ?? null;
if ( ! $jquery || empty( $jquery->deps ) ) {
return;
}
// Leave jquery-core in place; only strip the Migrate dependency.
$jquery->deps = arrayvalues( arraydiff( $jquery->deps, array( 'jquery-migrate' ) ) );
} );
Before you deploy that, spend an afternoon with Migrate’s own logging switched on. Load the uncompressed build (set SCRIPT_DEBUG to true in wp-config.php) and walk the site: archive, single, search results, 404, comment form, any page with a gallery or a form. Every JQMIGRATE console warning is a real API call in real code that will throw once Migrate is gone. Fix or report each one before you remove it.
We’ve shipped this change on sites where we had no intention of ever going fully vanilla, purely because it removes a request and a startup cost for zero risk once the warnings are clear.
Audit: find every handle and every call site
Start with the dependency graph. The handles that declare jquery as a dependency are the ones that will break hardest. Drop this in an mu-plugin while you work:
addaction( 'wpprintfooterscripts', function () {
if ( ! currentusercan( 'manage_options' ) ) {
return;
}
$scripts = wp_scripts();
$dependents = array();
foreach ( $scripts->registered as $handle => $script ) {
// inarray covers direct deps; recursedeps walks the whole chain.
if ( in_array( 'jquery', (array) $script->deps, true )
|| in_array( 'jquery-core', (array) $script->deps, true ) ) {
$dependents[] = $handle . ( in_array( $handle, $scripts->done, true ) ? ' [LOADED]' : '' );
}
}
printf( "<!-- jQuery dependents: %s -->\n", esc_html( implode( ', ', $dependents ) ) );
} );
Crawl ten representative URLs with that in place and diff the comments. You’ll find handles you forgot existed. On one agency site the list included a Google Maps wrapper from a plugin that had been deactivated for two years but still had an orphaned enqueue hook firing from the theme’s functions.php.
Static grep catches the rest of the theme:
grep -rnE '(jQuery|\$\()' wp-content/themes/your-theme \
--include='.js' --include='.php' | grep -v '\.min\.js'
Grep misses dynamic calls, inline wpaddinline_script output and anything concatenated. For those, instrument the runtime. Load this as a script with jquery as its dependency, walk the site, then call __jqReport() in the console:
(function () {
const real = window.jQuery;
if (!real) return;
const used = new Set();
const wrapped = function (...args) {
const instance = real.apply(this, args);
return new Proxy(instance, {
get(target, prop) {
if (typeof prop === 'string' && typeof target[prop] === 'function') {
// Frame 2 is the caller, past this handler and the Proxy trap.
const frame = (new Error().stack || '').split('\n')[2] || '';
used.add(prop + ' [...used].sort();
})();
You now have an exact list of jQuery methods in use with the file and line that called them. In our experience the list is shorter than anyone expects: typically 8 to 15 distinct methods, mostly on, addClass, attr, each, ajax and one animation call.
The rewrite patterns that actually come up
Event delegation
$(document).on('click', '.accordion-toggle', fn) is the single most common pattern and it needs a small helper, because writing closest() checks inline everywhere gets old fast:
export function delegate(root, type, selector, handler) {
root.addEventListener(type, (event) => {
const match = event.target.closest(selector);
// Guard against matches outside root when root is not document.
if (match && root.contains(match)) handler.call(match, event, match);
});
}
delegate(document, 'click', '[data-accordion-toggle]', function (event) {
event.preventDefault();
const panel = document.getElementById(this.getAttribute('aria-controls'));
const open = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', String(!open));
panel.hidden = open;
});
Notice what that example does that most jQuery accordions never did: it maintains aria-expanded and uses the hidden property. Rewriting is your chance to fix accessibility that jQuery’s slideToggle() let you ignore. That’s the same reason we argue for rebuilding rather than porting carousel components.
AJAX
$.ajax to fetch is mechanical, with one WordPress-specific trap: you must send cookies and the nonce.
async function postAction(action, data) {
const body = new FormData();
body.append('action', action);
body.append('ajaxnonce', window.themeAjax.nonce);
Object.entries(data).forEach(([k, v]) => body.append(k, v));
const res = await fetch(window.themeAjax.url, {
method: 'POST',
body,
credentials: 'same-origin' // admin-ajax needs the logged-in cookie
});
// fetch resolves on 4xx/5xx, unlike $.ajax which would call error().
if (!res.ok) throw new Error(admin-ajax ${res.status});
return res.json();
}
If the only reason your theme loads jQuery is an AJAX contact form, consider whether you need the endpoint at all. On static builds and brochure sites we point the form at WebForms and submit it with the same twelve lines of fetch, which removes the admin-ajax round trip, the nonce lifecycle and the spam handling in one go.
Animation
Do not reimplement slideToggle in JavaScript. Use CSS. For height transitions, the grid trick works everywhere today:
.panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 240ms ease;
}
.panel[data-open="true"] { grid-template-rows: 1fr; }
.panel > * { overflow: hidden; }
/ Newer engines can animate to auto directly. Progressive enhancement. /
@supports (interpolate-size: allow-keywords) {
.panel { interpolate-size: allow-keywords; }
}
@media (prefers-reduced-motion: reduce) {
.panel { transition-duration: 1ms; }
}
For fades and anything needing a completion callback, element.animate() returns a promise via .finished. That covers every .fadeIn(400, callback) you’ll find.
The utilities
$.each becomes forEach. $.trim becomes String.prototype.trim. $.extend becomes object spread. $('.x').addClass('y') becomes document.querySelectorAll('.x').forEach(el => el.classList.add('y')). None of this is interesting, and all of it is faster to write than to read about.
How to remove jQuery in WordPress without breaking plugins
Once the theme is clean, you have three options. Only one of them is usually right.
Option one: conditional dequeue. Leave the registration intact, and dequeue on the templates where nothing needs it. Safest, least satisfying, and it still removes the request from most page views.
addaction( 'wpenqueue_scripts', function () {
if ( isadmin() || iscustomize_preview() ) {
return;
}
// Any template where a jQuery-dependent plugin may render output.
if ( issingular( 'product' ) || iscart() || is_checkout() ) {
return;
}
wpdequeuescript( 'jquery' );
wpdequeuescript( 'jquery-core' );
}, 100 );
Option two: full deregister. Appropriate only when your dependent list from the audit is empty on the front end. Use wpderegisterscript() rather than dequeue so that a plugin enqueueing with a jquery dependency later doesn’t resurrect it. One important caveat: when a dependency cannot be resolved, WP_Scripts prints neither the dependency nor the dependent script. A plugin’s JS goes quietly missing rather than throwing $ is not defined. Silent is worse than loud. Log it in staging.
Option three: a compatibility shim. Ship cash-dom (roughly 6KB minified, about 2.5KB compressed) registered under the jquery handle. We’ve used it exactly once, as a two-week bridge on a site with an third-party booking widget we couldn’t modify. It is not a destination. Cash covers selectors, classes, events and basic AJAX, and fails on $.Deferred, Backbone and anything using $.fn.extend internals. If you find yourself patching the shim, you’ve chosen wrong.
Whichever route you take, use WordPress 6.3+ loading strategies for your own scripts so you’re not just swapping one blocking request for another:
wpenqueuescript(
'theme-main',
getthemefile_uri( 'assets/js/main.js' ),
array(),
'2.4.0',
array( 'strategy' => 'defer', 'in_footer' => true )
);
When to leave jQuery exactly where it is
We abort on two signals, without debate.
WooCommerce with classic cart and checkout. jQuery is load-bearing there: country and state selectors, the blockUI overlays, variation forms, fragment refresh events that extensions hook into. Shipping blocks checkout changes the picture for the checkout page itself, but the rest of the classic templates and the extension ecosystem still assume jQuery is global. Removing it costs you days and buys you 30KB on a site where conversion work on the product page is worth a hundred times more.
Legacy page builders and heavy plugin stacks. If the site runs a builder whose entire front-end runtime is jQuery widgets, your theme’s jQuery usage is a rounding error. Spend the budget on image formats and font loading instead.
There’s a third, softer case: the client with no retainer who will add plugins after handover. Deregister jQuery there and the first plugin they install breaks, and you get the support call. Ship option one with a documented filter, or ship Migrate removal only.
If you’re starting fresh rather than migrating, build vanilla from day one. A WordPress vanilla JavaScript theme is a straightforward proposition in 2026: ES modules, customElements for anything stateful, CSS for motion. That’s how CanvasWP handles interaction in its newer component set, and it’s the approach we default to on greenfield builds. It pairs well with progressive enhancement, since vanilla components tend to degrade to working HTML when the script fails to load.
The QA pass that catches what you missed
Automated tests won’t save you here, because the failures are visual and conditional. This is the list we work through:
- Console open,
Preserve logticked, walk every template type including 404 and search with zero results. Any$ is not definedorjQuery is not definedis a blocker. - Log in as subscriber, editor and admin. The admin bar loads different scripts and some plugins only enqueue front-end jQuery for logged-in users.
- Submit every form. Check success and validation-error states, not just the happy path.
- Test the comment reply threading. Core’s
comment-reply.jsis already vanilla, but theme overrides frequently are not. - Run the site with JavaScript disabled entirely. Anything that disappears was never progressively enhanced, and now is the moment to fix it.
- Re-run your WebPageTest profile and compare medians against the baseline you captured on day one.
Frequently Asked Questions
Does removing jQuery break the WordPress block editor?
No, provided you scope your changes to the front end with an isadmin() guard. The block editor is React-based, but plenty of core admin code and nearly every plugin settings screen still depends on jQuery, so deregistering it in the admin will break media modals, meta boxes and the Customizer. Always gate on isadmin() and iscustomizepreview().
Is it safe to remove jQuery Migrate on its own?
Usually yes, and it’s the right first step. Enable SCRIPT_DEBUG, browse the whole site with the console open, and fix every JQMIGRATE warning you see before removing it. If no warnings appear across your main templates, nothing on the site is relying on the shimmed APIs.
How much faster will my site actually be?
Expect roughly 30 to 35KB less compressed JavaScript and somewhere between 60 and 120ms less main-thread work on mid-tier mobile hardware. Because core enqueues jQuery in the head without defer, the bigger win is often removing a blocking request from the critical path rather than the bytes themselves. Measure with WebPageTest on a throttled device profile, five runs, median.
What happens to plugins that declare jQuery as a dependency?
If you deregister the jquery handle, WPScripts cannot resolve the dependency and skips printing that plugin’s script entirely. The plugin’s feature silently stops working, with no console error pointing at the cause. Audit dependents first with a script that inspects wpscripts()->registered, and prefer conditional dequeue on sites where plugins will be added after handover.
Should I use cash-dom or Zepto as a drop-in replacement?
Only as a time-boxed bridge for third-party code you genuinely cannot modify. Cash covers selection, classes, attributes and events in about 2.5KB compressed, but it will fail on $.Deferred, Backbone-based code and anything touching jQuery internals. If you start patching the shim to keep a plugin alive, keep real jQuery instead and revisit later.
Where to start tomorrow
Pick one decision. If the site runs WooCommerce classic checkout or a jQuery-based builder, remove Migrate, add defer to your own scripts, and close the ticket. Everything else is a poor use of the budget.
If the site is a content or marketing build, spend an hour running the dependency audit and the Proxy logger. You’ll end up with a list of a dozen method calls and three plugin handles. That list tells you whether this is a one-day job or a two-week one. Most of the time it’s one day, and the code you’re left with is code a new developer can read without learning a library first.

