« Back to home

The real reason to avoid jQuery

Or How to avoid common SPA pitfalls when using 3rd party plugins

This is a cross post of blog post I wrote and published for IsleOfCode on January 26, 2016 here.


In the past few years, I've read countless blog posts, twitter rants and medium exposés detailing how jQuery isn't necessary, and why you should just write with "native" JavaScript.

I disagree with these posts, because there are still a number things which jQuery handles which are not easy to replicate quickly unless you know the edge cases you need to handle.

There is, however, a very major reason to avoid jQuery, and it has very little to do with features or file-size.

jQuery is smothering your SPA's performance.

I don't mean a little. I don't mean "just a bit here and there". I don't mean "just for that one critical piece you should optimize".

I primarily work with Ember applications that are built to work well across every platform. When someone mentions that their Ember application is running slow, their memory usage is high, interactions are sluggish, or their scroll experience is choppy, my first step is to look at what and how they are using jQuery. 19 times out of 20, these problems originate in jQuery code, or jQuery plugins.

Standard caveat, before I dive into the details: avoiding jQuery and jQuery plugins isn't going to make your code suddenly perform better, only well written code is going to do that.

Mistake 1: Not tearing down event listeners

No, this is not a problem with jQuery, this is a problem with all event handling. In Ember, if you setup any event listeners in didInsertElement or willInsertElement, you must properly destroy them when that element is destroyed (using the willDestroyElement hook).

In order to correctly destroy the handler, you need either to have cached a reference to the handler function, or have scoped it. In Ember, I often use the component's guid to scope handlers.

Example 1

Component.extend({

  swipeHandler() { .. do something .. },

  willInsertElement() {
    jQuery(this.element).on('swipe', this.swipeHandler.bind(this));
  },

  willDestroyElement() {
    jQuery(this.element).off('swipe', this.swipeHandler.bind(this));
  }

});

Above, we think we're properly removing the handler afterwards, but because we've used bind twice, these handlers are different references. The correct way to do this is either to use jQuery(this.element).off('swipe') to remove all of the listeners, or to have cached the initial bound version we supplied to on.

The same goes for "roll your own" event handlers, you must maintain a reference to the handler to be able to properly destroy it.

When you don't destroy an event handler, the DOM node it references, any DOM children, as well as anything in the handler's scope (likely your component) are retained. At best, this is just a memory leak, but it can turn into more than just a memory leak, which we'll see when we reach our second common mistake.

Before we get there, let's review a few more patterns from this mistake.

Example 2

Component.extend({

  swipeHandler() { .. do something .. },

  willInsertElement() {
    jQuery('li.swipeable').on('swipe', this.swipeHandler.bind(this));
  },

  willDestroyElement() {
    jQuery('li.swipeable').off('swipe');
  }

});

This example is probably the most common mistake I see. Here, the developer has made the assumption that all items on the screen that match li.swipeable ought to have the event listener. But in fact, if you have more than one component adding swipeable list items, then you've added duplicate listeners to each, and when you teardown one component you also teardown the listeners attached by the other component.

I've seen people be smart enough to scope their handlers at this point.

Scoped (but buggy) Usage

 jQuery('li.swipeable').on(`swipe.${this.id}` ...

 ...

 jQuery('li.swipeable').off(`swipe.${this.id}`);

But this only prevents tearing down listeners that don't belong to your component, it doesn't fix the fact that you have attached duplicate listeners to many of the items.

The proper way to do this is to "scope" the jQuery selector either by starting from this.$() in your Ember component or jQuery(this.element) and using find. It's still a good idea to scope when you are attaching to elements that aren't the component itself.

Correct Usage

jQuery(this.element).find('li.swipeable').on(`swipe.${this.id}`, fn);  

Mistake 2: Using global jQuery selectors

This mistake is actually several mistakes that quickly compound. We first saw the problems with using global selectors above, when our component unintentionally added duplicate event listeners to swipeable list items within another component.

In general, the answer here is definitively to use the jQuery selector which is scoped to the component. But when you do ignore this advice, herein is also a really really fun bug.

These return HTMLCollections, which are LIVE node-lists (meaning they update whenever the DOM updates).

The document and element varieties of getElementsByClassName are live as well. While querySelector and querySelectorAll are supposed to be static (Update: the first version of this post erroneously had live vs. static selectors switched).

Sizzle, the selector library powering jQuery, uses these selectors whenever it can. The higher up the DOM chain you begin a LIVE list, the more changes it will observe, and the more work it will do.

When you use a global selector to do something, whenever it updates... a whole bunch of bookkeeping work must be performed and a whole bunch of jQuery code runs.

In SPAs, your DOM tends to experience two forms of "critical performance moments", (1) when transitioning from route A to route B, and (2) when using recycling or occlusion to reduce the amount of DOM on screen for long-lists. A single jQuery selector utilizing a live node list is all it takes to cause frames in either of these situations to become choppy.

Mistake 3: Cacheing jQuery Selectors for too long

How many "performance" tutorials tell you to cache your jQuery selectors? It's not that this is a bad idea, it's not. You just need to make sure to null it when you are done using it for a time. Cached selectors often lead to retained DOM nodes and become memory leaks, fortunately this often is temporary (until a containing component is destroyed, or some such), but depending on how and where you've done the cacheing, the leak could be more permanent.

To be fair, this is no different than cacheing an element or array of elements on your own, and forgetting to de-reference it later when you are done. This isn't a jQuery bug, so much as it's a mistake I see made a lot because of the mantra drilled into developers heads to "always cache jQuery selectors".

Mistake 4: Using, or at least not understanding jQuery Eventing

jQuery.Event.fix(e); is still a useful feature which fixes a lot of cross-browser issues. Every event you receive in a handler attached by jQuery receives a "fixed" event. This is great, and if it were all jQuery did, we could applaud jQuery and move on.

But it's not all jQuery does, and it's the other thing that jQuery does that I see lead to hard to trace bugs, test suites that fail (even though manual testing can't replicate) and the like.

Here's a Twiddle to demonstrate what I'm about to walk you through.

Raise your hand if you knew that Events have a "capture" phase and a "bubble" phase, and that the capture phase actually happens before the bubble phase.

Really? You did!? You're a lightyear ahead of most folks, and we'll get to some key performance tweaks just for you in a later blog post.

If you don't know, here's how eventing actually, really, works. Not jQuery eventing, real honest to goodness DOM eventing.

(You may also want to read the docs for addEventListener).

Imagine the simple DOM structure below.

 body
 |_ div
   |_ li.onclick

The li above has an inline onclick handler attached to it.

Now, let's attach 6 additional handlers for click, one each using the "capture" option on body, div, and li, and one each without that option.

Here's the order in which these events will trigger if you click on the li.

  1. (capture) body
  2. li.onclick
  3. (capture) div
  4. (capture) li
  5. (bubble) li
  6. (bubble) div
  7. (bubble) body

Now let's say you are running some automated tests, or perhaps you have some reason in your code to programmatically trigger a click on the li, and you do so with jQuery('li').trigger('click') or some such.

What order would you expect the handlers to trigger in? The same order as these native handlers, yes?

Assuming you attached all of these handlers with addEventListener, the new order will be:

2, 1, 3, 4, 5, 6, 7

Assuming you attached everything not using capture using jQuery.on, the order will be

5, 2, 6, 7, 1, 3, 4

Wait, say wut?

jQuery introduces two bugs.

  • it detects handlers directly attached to elements and alters them to be jQuery handlers instead
  • it "fast paths" event resolution by delegating to any handlers attached via jQuery before releasing the event for it's normal lifecycle, moving the bubble phase artificially in front of the capture phase (and only when you use trigger).

What this means is that your handlers run in a very different order during your automated tests or whenever you use trigger than they do during real usage.

It get's better.

Actually it doesn't, I didn't mean that. Now that we've used trigger once, let's trigger a real, native click and see what happens.

The following is true whether bubble handlers were attached by you with addEventListener or with jQuery.on

1, 3, 4, 5, 2, 6, 7

o.O WUT

jQuery left the onclick handler as a jQuery based bubble handler, so it now fires after our original bubble handler on the element.

What's the solution? Either don't use jQuery, don't use jQuery trigger, or write code that expects this steaming messy pile of #redacted.

Mistake 5: 3rd Party Widgets and jQuery Plugins, pretty much all of them.

You're on a short turnaround, you need drag-and-drop, or a carousel, or some special image viewer, etc. You remember that jQuery plugin you used to use for that which works amazing, it's just what you need. Maybe someone even already wrote a little ember/react-shim for it. All the better, you're up and running fast.

A few days later you get your first crash report: the app is unresponsive. Then they start piling in, crashes, out of memory errors, sluggish behavior; your memory use is out of control.

You've used this plugin a thousand times, what happened?

Here's what happened.

The world has changed.

For over a decade, Javascript developers were able to sneak by with lazily programmed code. This is especially true of jQuery plugins, but I see it elsewhere too. I recently saw someone discover Google's Map component suffers these problems. Really, it's every Javascript library not built for SPAs, and even a good many that are, that you have to be wary of.

In the past, developers didn't need to think about teardown, cleanup, or destroy routines for their Javascript code. A plugin / library was instantiated on page load, and existed until a full refresh or full new page load occurred.

This code is not prepared for dynamic web pages, and few have gone back to fix the situation. Why bother? The plugin pattern is largely dead, components and component-based frameworks have largely accomplished that.

The odds are overwhelming that your favorite jQuery plugin never cleans up after itself, that every DOM node, image, handler and object it knows about is forever retained. Remember the dangers with live selectors above? How many of these plugins did you instantiate with that call, how many do you think cached that selector themselves?

The leaks and perf hits are everywhere, and they are overwhelming.

As a rule of thumb, never implement a library that doesn't implement destroy, teardown cleanup or similar routines, and always make sure to call these routines when you are destroying the component which instantiated the plugin or library.

Even those libraries that do implement these routines often do a poor job with their actual cleanup. So your only recourse is to implement and then make sure to do some profiling with the library in use to catch it if they didn't.

.. and if they didn't

You've probably just found what you should build as your first Open Source ember-addon. Welcome to Open Source.