jQuery vs Vanilla JavaScript in 2026: Do You Still Need jQuery?
jQuery 4 is still 27 KB gzipped. Why I dropped it, the native APIs that replace it in 2026, a jQuery-to-vanilla cheat sheet, and when keeping it is fine.
In 2021 I was working at a web agency. Mostly brochure sites, with the same JavaScript kit every time: a slider, a bit of Ajax, lazy loading. The pages scored between 90 and 100 on Dareboost, and 90 to 100 on PageSpeed Insights for desktop too. On mobile, though, the score dropped.
Digging through the PageSpeed reports, the same culprit kept showing up: jQuery. When jQuery UI was loaded as well, the mobile score went down further. I ended up removing jQuery from my projects and writing plain JavaScript instead. I wrote a post about it back then; it was rough, and in hindsight wrong on several points. This is the rewrite, updated for 2026.
By the end you will know what jQuery actually costs today, what replaces each of its common methods, and when keeping it is still the sensible call.
What jQuery is, and what it no longer is
jQuery is a JavaScript library released in 2006. It offered two things: a short syntax for DOM manipulation, events and Ajax, and, more importantly, a layer that papered over browser differences. In the Internet Explorer 6 to 8 era, that second part saved days of work.
That problem is gone. querySelectorAll, classList, fetch, closest and addEventListener behave the same in every current browser. What is left of jQuery’s value is the syntax.
The library is not dead, either. jQuery 4.0.0 shipped on January 17, 2026, the first major release in almost ten years. It drops Internet Explorer 10 and older (IE 11 stays supported until jQuery 5), removes a batch of deprecated helpers (jQuery.trim, jQuery.isArray, jQuery.parseJSON, jQuery.type…), moves its source to ES modules and supports Trusted Types. The upgrade guide covers the rest.
How big is jQuery in 2026
My old post said jQuery 3.6.0 minified was “only 87.4 KB”. The number was right, the “only” less so, and it is not what goes over the wire anyway: servers compress. I measured again using the official files from code.jquery.com:
curl -sO https://code.jquery.com/jquery-4.0.0.min.js
curl -sO https://code.jquery.com/jquery-4.0.0.slim.min.js
curl -sO https://code.jquery.com/jquery-3.7.1.min.js
for f in jquery-*.js; do
printf '%s: %s bytes minified, %s bytes gzip\n' \
"$f" "$(wc -c < "$f")" "$(gzip -9c "$f" | wc -c)"
done
Results:
| File | Minified | Minified + gzip |
|---|---|---|
| jquery-3.7.1.min.js | 87,533 bytes | 30,215 bytes |
| jquery-4.0.0.min.js | 78,748 bytes | 27,398 bytes |
| jquery-4.0.0.slim.min.js (no Ajax, no Deferred) | 56,032 bytes | 19,375 bytes |
Measured the same way, the full jQuery UI 1.14.1 bundle (jquery-ui.min.js) is 252,957 bytes minified, about 67 KB gzipped. A custom build is smaller, but that is rarely what you find on a brochure site.
27 KB compressed is not huge. The real cost is elsewhere: that code has to be downloaded, parsed and executed before your own script can use it. On a low-end phone, execution time is what hurts, and it is exactly what PageSpeed Insights measures in mobile mode, with a deliberately throttled CPU. Paying that to run three addEventListener calls and one fetch is a bad deal.
To see what a page really uses, the Coverage panel in Chrome DevTools (⋮ menu › More tools › Coverage) shows, per file, how much code never ran during load. On a brochure page, jQuery’s red bar usually speaks for itself.
The three-button example, 2021 vs 2026
The example from the original post: three buttons, and clicking one shows its number.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Three buttons</title>
<script type="module" src="buttons.js"></script>
</head>
<body>
<button class="btn" type="button">Button 1</button>
<button class="btn" type="button">Button 2</button>
<button class="btn" type="button">Button 3</button>
</body>
</html>
The jQuery version used $(document).ready(...). That form has been deprecated since jQuery 3.0 in favor of $(handler):
$(function () {
$('.btn').on('click', function () {
const index = $('.btn').index(this) + 1;
alert('You clicked button ' + index);
});
});
In 2021 my “plain JavaScript” version was twelve lines long: a home-made ready function, then [].forEach.call(...) to loop over the elements. I concluded native code was twice as long. In reality I was writing 2012-era JavaScript. Here is the current equivalent, in buttons.js:
document.querySelectorAll('.btn').forEach((button, i) => {
button.addEventListener('click', () => {
alert(`You clicked button ${i + 1}`);
});
});
Five lines, no dependency. Two things changed:
- No need to wait for the DOM. A script loaded with
type="module"is deferred by default: it runs after the HTML is parsed, like a classic script withdefer. Thereadywrapper has no job left. NodeListhas its ownforEach. The[].forEach.calldetour was for old browsers.
Better: event delegation
One listener per button works, but buttons added later (by an Ajax call, say) would not get one. Delegation fixes that with a single listener on a parent, and closest finds the button even when the user clicked an icon inside it:
<div class="actions">
<button class="btn" type="button" data-number="1">Button 1</button>
<button class="btn" type="button" data-number="2">Button 2</button>
<button class="btn" type="button" data-number="3">Button 3</button>
</div>
document.querySelector('.actions').addEventListener('click', (event) => {
const button = event.target.closest('.btn');
if (!button) return;
alert(`You clicked button ${button.dataset.number}`);
});
That is exactly what jQuery’s $('.actions').on('click', '.btn', ...) does, minus the library. The number comes from a data-* attribute read through dataset rather than from the button’s position in the page, so you can reorder the buttons without breaking the script.
jQuery to vanilla JavaScript cheat sheet
The methods I used most, and their native equivalent. Everything below works in current browsers without a polyfill.
| jQuery | Vanilla JavaScript |
|---|---|
$(fn) / $(document).ready(fn) |
<script defer> or <script type="module">; otherwise document.addEventListener('DOMContentLoaded', fn) |
$('.a') |
document.querySelectorAll('.a') |
$('#id') |
document.getElementById('id') or document.querySelector('#id') |
$el.find('.b') |
el.querySelectorAll('.b') |
$el.closest('.c') |
el.closest('.c') |
$el.parent() |
el.parentElement |
$els.each(fn) |
els.forEach(fn) |
$el.on('click', fn) |
el.addEventListener('click', fn) |
$el.on('click', '.btn', fn) |
one listener on el + event.target.closest('.btn') |
$el.one('click', fn) |
el.addEventListener('click', fn, { once: true }) |
$el.off('click', fn) |
el.removeEventListener('click', fn) |
$el.trigger('refresh') |
el.dispatchEvent(new CustomEvent('refresh', { bubbles: true })) |
$el.addClass('x') |
el.classList.add('x') |
$el.removeClass('x') |
el.classList.remove('x') |
$el.toggleClass('x') |
el.classList.toggle('x') |
$el.hasClass('x') |
el.classList.contains('x') |
$el.attr('href') |
el.getAttribute('href') |
$el.attr('href', url) |
el.setAttribute('href', url) |
$el.data('id') |
el.dataset.id (always a string, no type conversion) |
$el.text() / $el.text(s) |
el.textContent |
$el.html(s) |
setting el.innerHTML (never with user input) |
$el.empty().append(node) |
el.replaceChildren(node) |
$el.val() |
el.value |
$el.css('color', 'red') |
el.style.color = 'red' |
$el.hide() / $el.show() |
el.hidden = true / el.hidden = false |
$el.append(node) |
el.append(node) |
$el.remove() |
el.remove() |
$el.fadeIn() |
CSS transition, or el.animate(...) |
$.ajax / $.get / $.getJSON |
fetch(url) then response.json() |
$.extend({}, a, b) |
{ ...a, ...b } or Object.assign({}, a, b) |
$.extend(true, {}, a) |
structuredClone(a) |
$.trim(s) (removed in jQuery 4) |
s.trim() |
One behavioral difference worth knowing: a jQuery selector that matches nothing returns an empty collection, and methods called on it silently do nothing. document.querySelector returns null, and null.classList throws. Optional chaining covers the cases where absence is expected: document.querySelector('.banner')?.remove().
Ajax with fetch
That was the second use of jQuery on my brochure sites. Loading content with fetch and async/await:
const list = document.querySelector('.news');
async function loadNews(page) {
const response = await fetch(`/api/news?page=${page}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const items = await response.json();
for (const item of items) {
const li = document.createElement('li');
li.textContent = item.title;
list.append(li);
}
}
loadNews(1).catch((error) => console.error(error));
The classic trap: unlike $.ajax, fetch does not reject on HTTP errors. A 404 or a 500 is a valid response; you have to check response.ok yourself. The promise only rejects on a network failure.
To submit a form, FormData replaces $(form).serialize():
const form = document.querySelector('#contact');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
});
form.querySelector('.status').textContent =
response.ok ? 'Message sent.' : 'Sending failed.';
});
The browser sets the Content-Type header (multipart/form-data) itself; do not set it by hand.
Animations, sliders and lazy loading without a library
The other two staples of my brochure sites, sliders and lazy loading, now have native answers, often with no JavaScript at all.
fadeIn / slideUp effects are a CSS transition on a class that JavaScript merely toggles:
.panel {
opacity: 0;
transition: opacity 200ms ease-out;
}
.panel.visible {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.panel { transition: none; }
}
document.querySelector('.panel').classList.add('visible');
When the animation has to be driven from JavaScript (chaining, waiting for the end), the Web Animations API does what jQuery’s .animate() did and returns a promise:
const panel = document.querySelector('.panel');
const animation = panel.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 200, easing: 'ease-out', fill: 'forwards' },
);
await animation.finished;
(Top-level await works in a type="module" script.)
A simple slider can be built with CSS scroll snap: touch scrolling, momentum and stopping on each slide are handled by the browser.
.slider {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.slider > * {
flex: 0 0 100%;
scroll-snap-align: start;
}
Previous/next buttons take a few lines with slider.scrollBy({ left: slider.clientWidth, behavior: 'smooth' }).
Image lazy loading is an HTML attribute, loading="lazy", supported by every current browser:
<img src="photo.webp" alt="Shop front" width="800" height="600" loading="lazy">
Do not put it on the main image visible on load: it would be delayed, which hurts the very LCP that PageSpeed measures.
Several jQuery UI widgets have native equivalents too: <dialog> for modals, <details> for accordions, <input type="date"> for date pickers.
When keeping jQuery is fine
Removing jQuery is not a goal in itself. I keep it, or simply leave it alone, in these cases:
- An existing project that works. Rewriting thousands of lines of jQuery with no measurable reason is risk without benefit. Write new code in vanilla JavaScript and migrate as you touch things.
- WordPress. jQuery ships with WordPress and many themes and plugins depend on it. If the page loads it anyway, using it in your script costs nothing extra. The move to jQuery 4 is tracked by the core team; a good moment to check what your own scripts rely on.
- A jQuery plugin with no credible alternative. If a battle-tested plugin does exactly the job, replacing it with home-made code is not necessarily progress.
When upgrading to jQuery 4, the jQuery Migrate plugin logs calls to removed APIs in the console.
For a new brochure site, though, I no longer see a case for it: everything I did there with jQuery in 2021 fits in a few lines of native code.
Key takeaways
- jQuery 4.0.0 (January 2026) is about 27 KB gzipped, 19 KB for the slim build. The real cost is execution time on mobile, not the download.
type="module"ordeferreplace$(document).ready.querySelectorAll(...).forEachandaddEventListenerreplace$('.x').on(...).- Delegation (
event.target.closest(...)) replaces.on('click', '.btn', fn)and covers elements added later. fetchdoes not reject on a 404 or 500: checkresponse.ok.- CSS transitions, the Web Animations API, scroll snap and
loading="lazy"cover animations, sliders and lazy loading. - On a legacy or WordPress project that already loads jQuery, keeping it is reasonable; on a new site, it no longer adds anything.