Performant canvas rendering for modern browsers
Lucidchart is one of the most complex graphical apps on the web, which means good performance is an absolute must. Getting that performance out of each of our supported browsers has always been a challenge, and we’ve found the best ways to meet that challenge have changed dramatically over time.
This post explains in-depth how we have approached rendering over the years, culminating in Project Blackbird: a new strategy now serving over 3 million users.
Lucidchart circa 2009
When a Lucidchart prototype launched five years ago, the list of supported browsers included Internet Explorer 6. So while performance benchmarking and testing at the time pointed to canvas (rather than SVG) as the best choice for performant rendering, we were forced to use Google’s excanvas shim for IE6—because it emulated some canvas functionality using IE’s proprietary VML markup.
That decision meant that we had to allow arbitrary mixing of different DOM elements in with our canvas-rendered elements. This led to the initial implementation of Lucidchart’s renderer, in which each object was rendered onto its own canvas (or, in IE6, in its own VML container).
This turned out to be a huge performance win for the other browsers as well, as canvas rendering operations were relatively slow at that time, but browser compositing was relatively fast. It was faster to draw objects infrequently, onto many canvases, and move those canvases around, than it was to draw all the objects onto a single canvas each frame.
Lucidchart circa 2011
One major problem with rendering each object onto its own canvas is that the approach scales very poorly with larger documents. As Lucidchart began serving more and more professional users in 2011, the size of the content created by our users grew dramatically. Diagrams with many hundreds of objects became common, and browsers would frequently crash due to memory constraints as we attempted to draw every object on the document.
To mitigate this, we adopted the approach of dropping rendered items from memory when they were scrolled far enough off-screen, and then re-rendering them when they came close enough to the screen again. This got us manageable performance for large documents, with basically no penalty for smaller documents.
Lucidchart circa 2014
Using a separate canvas for each object still scaled very poorly as documents grew larger, but for different reasons. It turns out that very large documents tended to have some very large objects on them—a swim lane spanning several feet of printed space, for example, or lines connecting objects that are far apart. What’s worse, several of those objects tended to appear together on-screen. The memory footprint of those large objects would often cause serious performance problems or crashes for our most valued users.
In 2014, canvases had become much faster than they were in 2009, in all the major browsers. Common optimizations in our rendering pipeline in 2011 featured replacing canvas commands with layered DOM elements. But in 2014, we found that a majority of our frame times were spent in the browser’s own layout, rendering, and composition code rather than our carefully-optimized canvas rendering code.
We launched Project Blackbird as a research project focused on improving overall application performance. We flattened rendering onto a single canvas and simply re-rendered the affected portions of the view every time the document changed (or the user scrolled, zoomed, etc).
In short, we moved to a traditional desktop application rendering model, where our own code managed everything from scrolling to layering, rather than relying on the widely-varying implementation of those features by browser vendors.
Early technical proofs-of-concept were very promising, and we rapidly moved into work on our actual application.
The devil in the details
The assumption that moving to a single-canvas implementation would free us from the bulk of browser inconsistencies turned out to be patently false.
While we had moved rendering of the actual document contents onto a single canvas, we had retained some small UI hinting elements as DOM elements (such as resize and rotation handles around the edges of selected objects) because rendering these elements on the canvas would cause excessive repainting. It turned out to be impossible to reliably update the canvas and the overlaid DOM elements simultaneously in the current version of either Firefox or Safari, resulting in the DOM elements either leading or trailing the canvas update by one frame, depending on the browser. This triggered a full rewrite of the UI hints layer as a second full-screen canvas on top of the document content that is re-rendered each frame.
Initially, we created the illusion of native scrolling by using a large (empty) placeholder element inside a smaller, scrollable element. We listened for scroll events on that placeholder, and updated our view of the content. This allowed all native methods of scrolling to work: scroll wheels, click & drag on the actual scroll bars, page up and down keys, etc.
Performance is not only rendering
While the rendering engine updates made a dramatic improvement in performance for small-to-medium sized documents, there was still painful lag interacting with very large (10,000+ object) diagrams. And profiling tools showed that most of that time spent in our code lay outside the actual rendering pipeline.
For example, given that a rectangular area of content needed to be redrawn, getting the ordered list of items overlapping that area to render was a very expensive operation when sorting through thousands of items sequentially. We implemented an in-memory spatial index based on the work from the excellent rbush open source project that increased spatial search performance by a factor of over 1,000 for large documents.
Since we render our own text, we also found that text rendering seemed much slower than we expected. However, we eventually discovered that it was not the text rendering itself that was slow, but the heavy processing we were doing on text prior to rendering (parsing, spell-checking, layout, wrapping, etc). That discovery led to an effort to re-engineer the entire text processing and rendering pipeline, leading to a massive 10x increase in text processing performance.
The final product
As we were just beginning the Blackbird project, we implemented rendering performance monitoring in our existing application. We wanted to be able to accurately track the impact of the update across our actual users, rather than just on our development and testing machines.
We measured what percentage of documents are getting at least a few specific levels of performance. 60 FPS is the theoretical limit, and can’t be made better on most users’ displays. 30 FPS is consistently great performance. 10 FPS is a little rough, but still quite usable. 2 FPS is the lower limit of what’s legitimately usable in the application—anything worse than that, and people won’t be able to get any work done.
Without further ado, here’s the breakdown of the level of performance for our actual users, on whatever hardware and browsers they happen to be using:
Lucidchart used to give a totally useless experience in 5% of documents, and a poor experience in 25% of documents. It now gives a useless experience in less than 1% of documents, and a poor experience in 5% of documents.
We’re pleased with the results of Blackbird, but we want to continue to improve our performance results. Our goal is to ensure that 99% of documents render at least 10 FPS, and 95% of documents render at 30 FPS and above.
Working on Blackbird has given us the tools we need to actually reach that goal. The most important side effect of the project was the implementation of a way to authoritatively measure the real-world performance impact of each of our future releases. This not only allows us to react to a poorly-performing release rapidly, but gives us a way to know how well our future improvements will actually work in the wild.
With improved tools and strategies, we’re excited to be offering one of the highest performing graphical applications out there, and we’re committed to keeping it that way.
Ben Dilts is the CTO and co-founder of Lucid Software. He has successfully designed and implemented a variety of major software projects, especially web-based database solutions. He was the Chief Technology Officer for Zane Benefits, where he led the development of a leading online health benefits administration platform, which was later featured on the front page of the Wall Street Journal.