11/17/2018Time to read: 4 min
The target audience of this post are frontend developers of all levels
A while ago I worked on an interesting performance issue. Our application has a page that displays a complex table. The table can have at most 1000 rows, each row has a dozen columns. At the beginning of each row, we have a checkbox for the user to select and unselect a row. Customers reported that, when dealing with a table with 1000 rows, selecting/deselecting a row froze the whole table in Internet Explorer. We only support IE 11. When we run the page on IE 11, we indeed saw the slowness when we trying to deselect a row. The page went completely unresponsive and we have to close the browser. We tried the same page on chrome, it worked fine. Firefox also worked. Every time we see this kind of browser inconsistency, our manager liked to show us the IE eating glue image. How is checking a checkbox so slow in IE? Note that this only happens when we have a large number of rows. We tested with 300 rows and already saw the slowness when selecting a row. The more rows we had, the slower it gets. It was not hard to guess, selecting a checkbox somehow affects the whole table instead of just the individual row. Our first assumption was, selecting/unselecting a checkbox triggers some event, maybe the event was sent to every single row even though only the current row should receive it. We check the code for event listener, there wasn't any event listener. Could it be some recent changes? If we find that a previous release worked fine, we can look into any recent changes made to this part of the code. We looked at the history of the code, and it didn't change much since it was first implemented. We also run the old releases and experienced the same issue. This case might not be tested in IE when it was first implemented. Because IE's debugger is far more primitive than any other browser debugger and it would freeze when dealing with a complex page, we tried to reproduce the issue in Chrome debugger. We enabled CPU throttling to slow down Chrome, hoping it would match IE's speed. It did help. We were able to pinpoint one specific line of code that was slow:
var $row = this.$tbody.find('tr[data-row-id="' + id + '"]');
What this line did was getting the table row with an id.
This makes some sense. The more rows we had, the harder it was to get a row because browser might need to go through each row one by one until a row matches our id.
But would it completely freeze the browser though?
We tried getting the row in the IE debugger by running querySelector('tr[data-row-id="1234"]')
and it was able to return the row very fast.
Something was wrong with JQuery.
We went back to Chrome, ran the performance profiler, and selected a checkbox. Surprisingly the profiler showed that a large amount of time was spent in rendering, more specifically, style recalculation.
Chrome devtool was also very helpful in pointing out the code that caused this style recalculation.
// Capture the context ID, setting it first if necessary
if ( (nid = context.getAttribute( "id" )) ) {
nid = nid.replace( rcssescape, fcssescape );
} else {
context.setAttribute( "id", (nid = expando) );
}
This code is in Sizzle, JQuery's selector engine that normalize different behaviors among browsers for selecting an element. There was also a comment above the lines
// qSA looks outside Element context, which is not what we want
From what I understand, this is trying to overcome a quirk of querySelectorAll
. For example in the following HTML structure:
<div id="parent">
<div id="context">
<div id="target"></div>
</div>
</div>
When we select the target using desendent selector
const context = document.getElementById('context');
console.log(context.querySelectorAll('#parent #target').length);// 1
console.log($(context).find('#parent #target').length); // 0
JQuery believes that, when selecting an element inside an context, the selectors should only match elements inside the context. #parent
is outside of the context, so JQuery wouldn't return any findings.
JQuery internally still uses querySelectorAll
to select elements on a browser that supports it. But to override browser's default behavior for considering elements outside of context, it uses a very smart trick by retrieving the id on the context. So when we do
$(context).find('#parent #target')
JQuery internally override the selector to "#context #parent #target"
. If there isn't an id on the context element, it will generate a unique id, assign it to the context element, then remove it when finished selecting. This will guarantee that the selectors are scoped by the context.
Now back to the IE performance issue. Because we didn't have an id on the table. When we did $tbody.find('tr[data-row-id="' + id + '"]')
, JQuery added an id to our table body and removed it after find
.
Any issue with adding an id?
After some research, we found that
When JQuery added and quickly removed an id to our table body, the styles of all rows and columns were recalculated twice. IE wasn't very optimized with that. Who would think a performance issue that freezes the whole browser was caused by selecting an element! So the fix? We tested out assigning an id to the table body ourselves when the table was loaded. When JQuery saw an existing id on the context, it would just use it without meddling with table attributes. This improved the performance a lot, decreasing the wait time to 15 seconds (š¤¦before the change it wasn't moving at all no matter how long we waited...). There were other issues hindering the performance. Loading 1000 rows through the wire was already taking a long time in IE. In the end, we added pagination to the table for better user experience. Who would be able to read a table with 1000 rows?