This code will execute all at once when the JavaScript event loop reaches it, without yield, until all the points are rendered. If we call this code with a large enough number of data points on a button press, you'll see your browser hang before it even returns the button to its undepressed state. That's the easiest part to fix, actually, and it will lay the foundation for keeping the whole thing snappy, so let's start there.

Putting Functions on the Message Queue

To fix the browser hang, we slot our function at the end of JavaScript's message queue. This allows the browser to completely handle the button press event and repaint the button before it executes any of our drawing code. The easiest way to do that is wrap our code in a setTimeout:

function drawCircles(svg, data) { setTimeout(function() { var circles = svg.selectAll('circle').data(data); circles.enter().append('circle') .attr('r', 3); circles.exit().remove(); circles .attr('cx', function(d) { return d.x }) .attr('cy', function(d) { return d.y }); }, 0); }

In this gist you’ll notice that the button returns to its undepressed state before the circles finish being drawn. “That was easy!” you might say, but now comes the hard part. We've simply delayed the problem a little. While we’re adding and maneuvering all those circles on the page, the browser is still going to hang, and that’s what we’re trying to avoid. So now, we’re going to have to break our D3 Selection circles into batches, and render each batch in a separate message on the queue.

Breaking a D3 Selection into Batches

D3 doesn’t provide native functions for breaking up the selection above ( circles ) into batches for rendering. In other situations, you might be able to use select() or filter() (which give you a subset of a selection) but they don’t preserve the data-binding that provides the convenient joins called update , enter() , and exit() . You might try to break up the data into batches earlier, when you give it to D3, but then D3 would compute inaccurate joins leaving you with mismatched data-binding and fewer circles than you need.

A D3 selection is a subclass of an array, whose elements represent groups of DOM nodes as more arrays. Who doesn’t like an array of arrays? In our case, there is only one “group of DOM nodes” subarray, so circles[0] gets all the current DOM elements we want. We’ll also need circles.enter() and circles.exit() to have all the possible elements for our data. That’s because for each new element that is entering the data set, D3 puts a null placeholder in circles[0] , and the actual element in the corresponding slot of the enter() selection. That is to say, when we bind the data to our selection, if the nth datum does not have a corresponding element in the DOM, circles[0][n] is a new slot containing null , and circles.enter()[0][n] contains an element for the new datum.

Lastly, to get the first batchSize elements as represented in the update() , enter() and exit() joins, we can slice the arrays of DOM elements and wrap them in a new selection like so:

var updateSelection = d3.selectAll(circles[0].slice(0, batchSize)); var enterSelection = d3.selectAll(circles.enter()[0].slice(0, batchSize)); var exitSelection = d3.selectAll(circles.exit()[0].slice(0, batchSize));

Out with the Exit, In with the Enter!

So now we’ve got our three selections, and we want to perform the same operations as before. The way we handle updates and exits doesn’t have to change because these selections refer to actual DOM elements:

// equivalent to circles.exit().remove() in the un-batched example exitSelection.remove(); // equivalent to circles.attr in the un-batched example updateSelection .attr('cx', function(d) { return d.x }) .attr('cy', function(d) { return d.y });

But the way we handle enterSelection has to be completely different than how we handle circles.enter() . Calling enter() on a selection generates a sub-type of selection, inside of which are null s for all existing elements and dummy objects for each new element. This special sub-type of selection knows how to handle those null s when we call append() , but enterSelection , which was generated via selectAll() , is an ordinary selection, and will not handle null s correctly. The good news is that the dummy objects that enterSelection contains still have the data that we bound using data() , so we can use each to append the DOM elements we need and give them the right data:

enterSelection.each(function(d, i) { var newElement = svg.append('circle')[0][0]; newElement.__data__ = this.__data__; }).attr('r', 3);

This will get us an element in the DOM for every element in enterSelection , but the other convenient advantage of D3s joins is that they automatically update one another. Specifically, when you call append() on an enter() join, the DOM elements that are created are added into the enter() join as well as the update() selection you created the enter() join from. Our enterSelection is missing this functionality because it isn’t a real enter() join, so to do that we’ll have to modify enterSelection and updateSelection manually as we go along.

enterSelection.each(function(d, i) { var newElement = svg.append('circle')[0][0]; newElement.__data__ = this.__data__; enterSelection[0][i] = newElement; updateSelection[0][i] = newElement; }).attr('r', 3);

And that’s about it. Yes, we’re reaching into D3’s internals, but we’re walking away with a highly optimized batched rendering process.

Putting it All Together

The last step is to generalize this across multiple batches and split it across multiple timeouts. We do that by calculating a startIndex and stopIndex for a particular batch, and write a new drawBatch function to be called with setTimeout :

function drawCircles(svg, data, batchSize) { var circles = svg.selectAll('circle').data(data); function drawBatch(batchNumber) { return function() { var startIndex = batchNumber * batchSize; var stopIndex = Math.min(data.length, startIndex + batchSize); var updateSelection = d3.selectAll(circles[0].slice(startIndex, stopIndex)); var enterSelection = d3.selectAll(circles.enter()[0].slice(startIndex, stopIndex)); var exitSelection = d3.selectAll(circles.exit()[0].slice(startIndex, stopIndex)); enterSelection.each(function(d, i) { var newElement = svg.append('circle')[0][0]; enterSelection[0][i] = newElement; updateSelection[0][i] = newElement; newElement.__data__ = this.__data__; }).attr('r', 3); exitSelection.remove(); updateSelection .attr('cx', function(d) { return d.x }) .attr('cy', function(d) { return d.y }); if (stopIndex < data.length) { setTimeout(drawBatch(batchNumber + 1), 0); } }; } setTimeout(drawBatch(0), 0); }

We also had to add a condition to the end of the drawBatch function that calls itself for the next batch if there is another batch to draw. If you run this gist you’ll now notice that it takes a bit longer to render, but the entire page remains responsive while it happens.

A Responsive Visual Profiler

After all that digging through the internals of D3 and hacking together my own batched rendering, I connected the new chart to real data from our backend. Watching the scatterplot render progressively while the page remained responsive was the ultimate payoff. No jank, just a great user experience with a scatter plot that helps our users find the choke points in their MongoDB deployment.