Make It Faster
We describe how to squeeze performance out of the browser which can be applied to anyone wanting to tune their own applications. It will be of particular interest to users of ag-Grid to improve understanding of how to work with the grid. We also think that it will be of interest to anyone creating a grid. We relish the idea of healthy competition so we are happy to contribute to the wider community knowledge.
Hack 1 - Row Virtualisation
Row virtualisation means that we only render rows that are visible on the screen. For example, if the grid has 10,000 rows but only 40 can fit inside the screen, the grid will only render 40 rows (each row represented by a DIV element). As the user scrolls up and down, the grid will create new DIV elements for the newly visible rows on the fly.
If the grid was to render 10,000 rows, it would probably crash the browser as too many DOM elements are getting created. Row virtualisation allows the display of a very large number of rows by only rendering what is currently visible to the user.
The image below shows row virtualisation - notice how the DOM only has 5 or 6 rows rendered, matching the number of rows the user actually sees.
Hack 2 - Column Virtualisation
Column virtualisation does for columns what row virtualisation does for rows. In other words, column virtualisation only renders the columns that are currently visible and the grid will render more columns as the user scrolls horizontally.
Column virtualisation allows the grid to display large numbers of columns without degrading the performance of the grid.
Hack 3 - Exploit Event Propagation
Problem - Event Listener Registration
The grid needs to have mouse and keyboard listeners on all the cells so that the grid can fire events such as 'cellClicked' and so that the grid can perform grid operations such as selection, range selection, keyboard navigation etc. In all there are 8 events that the grid requires at the cell level which are click, dblclick, mousedown, contextmenu, mouseover, mouseout, mouseenter and mouseleave.
Adding event listeners to the DOM results in a small performance hit. A grid would naturally add thousands of such listeners as even 20 visible columns and 50 visible rows means 20 (columns) x 50 (rows) x 8 (events) = 8,000 event listeners. As the user scrolls our row and column virtualisation kicks in and these listeners are getting constantly added and removed which adds a lag to scrolling.
Solution - Event Propagation
6 of these 8 events propagate (the exceptions are mouseenter and mouseleave). So instead of adding listeners to each cell, we add each listener once to the container that contains the cells. That way the listeners are added once when the grid initialises and not to each individual cell.
The challenge is then working out which cell caused the event.
You might have noticed that we are attaching arbitrary attributes (
__row) onto the DOM element
and might be wondering is this safe? I hope so, as ag-Grid is used for air traffic control over Australia
as far as I know. In other words, ag-Grid has done this for a long time and has been tested in the field.
Hack 4 - Throw Away DOM
Good programming sense tells you to de-construct everything you construct. This means any DOM item you add to the browser you should remove in your clean down code. In the context of your framework, it means removing components from their parents when the component is disposed.
This hack goes as follows: if you are removing an item from the DOM (e.g. a grid cell) but you know the parent of that item is also going to be removed (e.g. a grid row) then there is no need to remove the child items individually.
So in ag-Grid, as rows are created, we use composition to build the complex structure into the DOM. However when removing the rows, we do not remove the cells individually from the DOM, instead we remove the entire row in one quick DOM hit.
Hack 5 - innerHTML where possible
What is the fastest way to populate lots of cells and rows into the browser? Should you use
document.createElement()) to create each element, update the attributes of each element
appendChild() to plug all the elements together? Or should you work off
document fragments? Alternatively should you create all the document in a large piece of HTML and then
insert it into the dom by setting the property
We have done many tests. The answer is to use
So ag-Grid leverages the speed of
.innerHTML by creating the HTML in one big string
and then inserting it into the DOM using the method
element.insertAdjacentHTML() is similar but slightly less well known
equivalent of the property
.innerHTML. The difference is that
insertAdjacentHTML() appends to existing HTML where as
replacing the current content. Both work just as fast.
This works well when there are no custom cell renderers. So for all cells that do not use cell renderers, the grid will inject the whole row in one HTML string which is the quickest way to render HTML. When a component is used, the grid will then go back and inject the components into the HTML after the row is created.
Cell renderers are a type of component. The component concept is great for applications. They
are the building blocks of the composite design pattern used for building large applications,
where smaller pieces (components) fit together to create bigger pieces. However, in ag-Grid,
we want the fastest grid possible, so it's best to avoid the use of cell renderer components
to leverage the power of
innerHTML for the fastest rendering of rows.
If you are a user of ag-Grid, you might be wondering how cell renderers impact performance. The answer depends on your platform, using Chrome or a small non-complex grid, they should not pose a problem. If you are displaying large grids using Internet Explorer, it is worth checking the impact your cell renderers are adding.
Hack 6 - Debouncing Scroll Events
When you scroll in ag-Grid, the grid is doing row and column virtualisation, which means the DOM is getting trashed. This trashing is time consuming and if processed within the event listener will make the scroll experience 'rough'.
To get around this, the grid uses debouncing of scroll events with animation frames. This is a common trick to achieve smooth scrolling and is explained very well in this blog Leaner, Meaner, Faster Animations with RequestAnimationFrame. As this technique is well explained in posts such as above, I won't repeat it here. Suffice to say, we found this delivers a good performance boost.
Hack 7 - Animation Frames
Even with all the above performance tunings, our users still asked us to "make it faster, it's not good enough in Internet Explorer, the scrolling is awful, it takes too long". Some users experienced lag times of two to three seconds in Internet Explorer for rows to draw after a scroll.
So the next performance hack was to break the rendering of the rows into different tasks using animation frames. When the user scrolls vertically to show different rows, the following tasks are set up in a task queue:
- 1 task, if pinning then scroll the pinned panels.
- n tasks to insert each rows container (results in drawing the row background colour).
- n tasks to insert the cells using string building and
- n tasks to insert the cells using cell renderers where applicable.
- n tasks to add mouseenter and mouseleave listeners to all rows (for adding and removing hover class).
- n tasks to remove the old rows (do not deconstruct cells, just rip the rows out).
So if you scroll to show 10 new rows, you will have 50+ tasks. Each scroll, row creation, cell creation etc. will be an individual task.
The grid then uses animation frames (or timeouts if the browser does not support animation frames) to execute the tasks using a priority order. The order ensures things such as:
- Scrolling will get done first, best efforts are made to keep pinned sections in line.
- Row containers are second, the first thing the user will see is the outline of the rows.
- Cells are drawn in stages, as quickly as the browser will allow while giving visual feedback to the user.
- Removing of old rows is left till last, as that has no visual impact.
- If a new scroll event comes in, then this gets priority again over older tasks of lower priority, keeping all pinned areas in sync as a priority.
- Tasks that are old are cancelled i.e. the user has scrolled past by the the time the row is to be rendered.
Having this many tasks should result in a lot of animation frames. To avoid this, the grid does not put each individual task into an animation frame. This would be overkill as then the create, destroy and schedule of animations frames would add their own overhead. Instead the grid requests one animation frame and executes as many tasks as it can within 60ms. We picked this timeframe following tests for the best user experience. If the grid does not exhaust the task queue, it requests another animation frame and tries again, and keeps trying until the task queue is emptied.
Fast browsers such as Chrome can get everything done in one animation frame and produces zero flicker. Slower browsers such as Internet Explorer can take 10+ animation frames to process the task queue for a standard scroll. The user experiences smooth scrolling with iterative feedback as the grid is rendered in stages, which is better than blocking the UI and painting everything in one go.
As we move through these improvements, you will notice we are using animation frames to add mouseenter and mouseleave. This is different to the other events where we use event propagation to listen for all other events at the parent level. mouseenter and mouseleave do not propagate, so instead we add these in an animation frame after the rows are rendered. This has minimal impact to the user. They are not waiting for these events to be added before they see the row on the screen.
The grid uses similar task priority queuing for horizontal scrolling. When the user scrolls to show more columns, the header gets scrolled first and the cells are updated next. This is all done using the same task queue and animation frames.
Hack 8 - Avoid Row Order
By default, the DOM created by the grid will have the rows appear in the order they were created. This can get out of sync with the rows on the screen as the user scrolls, sorts and filters. The row virtualisation trashing adds and removes rows as the user scrolls.
Screen readers and other tools for accessibility require the row order to be the same in the DOM as on the screen. Having the row order consistent with the screen causes a performance hit, as it stops the grid from adding rows in bulk.
So there is a trade-off. By default, the grid does not order the rows. If the user wants the row order,
they can use the property
ensureDomOrder=true. The grid works a bit slower but is compatible with screen readers.
All of the performance hacks above are the result of years of learning. They are tried and tested approaches for squeezing performance out of the browser. If you have more ideas to make things faster, or have any improvements suggestions on this blog, then please comment below. Even better, if this all seems easy to you and you could do better, get in touch as we are always looking to hire the right people.
One lasting note - these are performance hacks that worked for us in ag-Grid. You should understand them and consider them. They may not be suitable to your application (an application has different concerns to a data grid).
Sharing is caring! So please take the time to share this article, or up-vote on Reddit (link below).
CEO / CTO
Niall provides the technical vision for ag-Grid, juggling this with the usual CEO duties. 15 years of experience building Enterprise applications has given Niall unique insight into the challenges while equipping him with the technical skills to deliver the correct solutions. Niall focuses primarily on developing and maintaining the core of ag-Grid and is very much at the heartbeat of the company.