Rendering an HTML table with up to 2000 rows is slow, especially in Chrome


#1

Hi, I’ve been trying to use Meteor to render data pulled from Mongo in an HTML table, and I’ve been finding my client web-pages to be sluggish at data- and table-sizes below what I’d expected.

The problems appear to be specific to, or at least most pronounced in, Chrome; I’m running version 43.0.2357.130, 64-bit on OSX 10.10.4.

Here is a github repo with a dead-simple Meteor app that I’ve used to try to diagnose the problem: ryan-williams/meteor-test.

Here is a screencast of me running this test app, inserting records into Mongo via a meteor mongo client, and observing how long it takes before the web page displays the latest information.

As the video description notes, I start out by inserting 1000 records, and the page updates in 3-4s; that seems slow to me: the Mongo queries finish in a fraction of a second, the total amount of data in the DB at this point is ~112KB, and my browser (Chrome 43.0.2357.130, 64-bit) can render a 1000-row HTML table in well under 1s.

After doing the above, dropping the table, and doing it again, I insert a second batch of 1000 records on top of the first, and that takes 57 seconds. Something seems seriously amiss at this point.

Next, I drop the table again (~4s) and start over, inserting records 100 at a time.

For the first 6 batches (0 up to 600 records), the page updates within 1s of the mongo queries finishing, AFAICT; seems reasonable.

However, the 7th batch of 100 takes 7 seconds, and subsequent batches of 100 take longer and longer, up to ~20s from 1200→1300 and ~26s from 1400→1500. Mysteriously, the 1300→1400 batch finishes in about 1s.

The screencast also shows a running htop that mostly shows nothing happening on my computer during these delays other than my chrome tab chewing up 100% CPU; I can’t tell if it’s spending those cycles rendering HTML tables that it never actually displays to me, doing some Mongo bookkeeping inefficiently, or what.

There is an obvious pattern in the video where, when going from e.g. 1100 records to 1200 records, the client prints out that it is rendering the 1102-record version of the page, but nothing happens for many seconds, then suddenly that finishes and a 1200-record version is also rendered almost immediately.

I have observed similar latencies with even simpler versions of this test app, e.g. leaving out the { sort: { _id: -1 } } and/or the num: {{num}} label that calls a .count(), though in theory those shouldn’t be causing this slowness (and indeed, in practice they are not). There’s a combinatorial space that we could explore defined by toggling each of {sort by _id, adding a limit} on each of {client, server}; I’ve poked around a bit and not found any smoking guns.

I would really like to hear peoples’ thoughts on whether there are ways to have my pages respond more quickly, and why these particular strange behaviors are occurring.


#2

Blaze is not good when updating from a large changing subscription. Wait for the subscription to be ready before rendering and you should be fine.


#3

The primary problem here, is that blaze doesn’t wait & batch DOM operations in one go. (It only batches the initial render)

Which means, as each record is published via DDP, it’s inserting a DOM node immediately (bad news!). So when you have 1000 records, they’re being inserted one-by-one. However, the DOM doesn’t repaint in this time, so it looks like a single op.

You have a few ways to solve this, for your use-case:

  1. Don’t render such a large table! Limit how much data you’re publishing.
  2. For page loads - only render when the subscription is ready (As mentioned by @Steve)
  3. Don’t make it reactive - just render on template load, and add a refresh button.
  • pass reactive:false to Test.find
  1. Try ReactJs - it’s much better at batching DOM operations. More coming on this soon.

Personal Preference - If possible I would try to limit the amount of data published - as Meteor needs to keep a copy of published data in RAM; Also, users can’t usually look at more than 1 screen of data at once.


#4

Thanks @nathan_muir and @Steve, lots of good suggestions here; some discussion of / questions about each:

Wait for subscription to be ready

Is this only relevant on page load, or does the subscription go “un-ready” again while it is receiving data to a previously-fully-loaded/ready page? I thought the former (which seems to be corroborated by @nathan_muir’s comment #2 above). In my tests here (and the screencast above) the page is already loaded and waiting when I start inserting things into Mongo, so I’m not sure that this fixes my issues.

Make it non-reactive

While it’s good to keep in mind that this is always an option, the original project I embarked on here (that lead to this debugging/profiling exercise) is taking a frustratingly-unreactive web-app and porting it to Meteor for reactivity. I don’t think I’m doing anything that Meteor shouldn’t be able to support, so I’d like to preserve reactivity if possible.

Render a smaller table

I’ve thought a lot about this, and there are many tradeoffs worth discussing here. To spare you the long background story:

  • I might be OK with capping my tables at 100 rows (instead of 1000) and e.g. paginating them.
  • However, the point here is that I’m observing what I consider to be unreasonable latencies rendering 10-, 100-, and 1000-row tables.

All back-of-the-enveloping that I do suggests that it should be easily possible (given the speed of network connections, JS engines, etc. today) for a page to be reactive while displaying 100 or 1000 rows of data; here that represents at most tens to hundreds of KB of data, and browsers can render ~1000-row tables at a much higher frame-rate than I’m observing in my meteor-test app.

So, treating the 1000- and 100-row cases separately:

1. Why is rendering 1000 rows slow?

Per my aforementioned analysis, I am seeing much worse performance rendering a 1000-row table than I would expect, and I think it’s worth us discussing why that’s the case and how it could be improved upon.

In my screencast above, at 1:47, Chrome locks up for 7s while appending 100 rows to a 600-row table.

Here is another screencast showing various operations in Chrome, Firefox, and Safari; many of them just deadlock for 10s of seconds. Right out of the gate I insert 1000 records, FF and Safari finish in 20-30s, and Chrome finishes in 48s.

There’s just not enough data being sent to the client and rendered here that any of those seem like reasonable numbers, even if I can “get away with” rendering just 100 rows instead of 1000.

I think @nathan_muir’s point, that the primary problem is Blaze not batching DOM operations, is borne out pretty dramatically here, so I’m curious about whether this is something that MDG is thinking about / planning to address.

Before posting here, I experimented with batching Mongo writes, because I could tell that either Mongo or Meteor was struggling due to not batching updates. With that implementation, I write one Mongo record that has ~1000 sub-records, which are either published as separate records or rendered into a <table> directly. However, I was still seeing these DOM lock-ups, which I guess boils down to Blaze trying to write an entire HTML table for every update, and basically failing to render anything while doing so?

Given all of this, the recommendation to try out one of the meteor-react integrations makes sense, per @nathan_muir’s #4 above, and so I will try that next. I’d considered that as a possibility, and in some sense maybe the happiest conclusion to this story would be deciding that the issues I’m seeing are really just last-mile Blaze↔︎DOM inefficiencies, not more systemic issues deeper in the Meteor stack.

Unfortunately, other behavior I’m observing points to a need for batched-DDP…:

2. Displaying just 100 rows: differently slow

Even restricting my meteor-test repo to displaying the most recently-created 100 records, I still see surprisingly slow times-to-update on the client. This seems likely due to a combination of:

  1. Blaze attempting to render the page for every update instead of batching updates, and
  2. Blaze or the DOM locking up for inordinate amounts of time when it/they get behind performing #1.

Here is yet another (shorter) screencast of Chrome, Firefox, and Safari displaying just the most recently-created 100 Test records, taking ~1s to reflect 100 records being inserted, and taking several seconds to churn through 1000 records being inserted.

Firefox seems to be the fastest, Safari falls seconds behind ground-truth while displaying many (stale) intermediate states (but at least seems to successfully re-render the page many times between start and finish), and Chrome seizes up, rendering just a few intermediate snapshots at sub-1Hz frame-rates.

Here is my complete code, reflected at meteor-test 2e8a86:

Test = new Mongo.Collection("test");

if (Meteor.isClient) {
  Tracker.autorun(function() {
    Meteor.subscribe("test");
  });

  Template.table.helpers({
    num: function() {
      var r = Test.find().count() + ", " + new Date();
      console.log(r);
      return r;
    },
    records: function() {
      return Test.find({}, { sort: { _id: -1 } });
    }
  });
} else if (Meteor.isServer) {
  Meteor.publish("test", function() {
    return Test.find({}, {
      sort: { _id: -1 },
      limit: 100
    });
  });
}

For grins, I reduced the number of items displayed to 10, and observed similar latencies processing batches of 100 and 1000 updates; this seems to pin blame (in these reduced examples) on DDP not batching updates; Safari and Chrome in particular fall several seconds behind while rendering stale/intermediate snapshots of the underlying data.

Conclusions / Action Items

It seems like there are two sources of slowness that I’m seeing:

  1. inefficient interaction with the DOM when re-rendering one frame.
  2. DDP not batching updates, attempting to render too many (stale) frames.

wrt #1, I’m going to see if using React instead of Blaze improves things dramatically, and will report back.

wrt #2, it seems like some sort of DDP-update-batching will be essential for improving performance on these very-modestly-sized examples, so I’m interested in any further thoughts people might have on that matter, or anything else related to the issues I’ve documented here.

Thanks again for your help!


#5

You are not rendering 1000 row table, you are re-rendering 1000x table with row counts which vary from 1 to 1000.
Do not show the table till subscription is ready, so it will render only when it has all data.
If that onReady property works as I expect (indicating if whole sync is done after resubscribing).

Still it would not solve your issue.

The test case is really out of normal use. Why would you drop whole collection and show it as you are inserting data.
If you want test table rendering, fill in 20.000 records, pull 2000 to client, show 1000 in table and paginate over it.
So you dont have to wait for next batch of records from subscription and have it preloaded.
In that case you should need onReady only during 1st batch, to show initial “waiting for data” while waiting for data, as there will be none preloaded.


#6

Batching DOM updates in Blaze makes a lot of sense, particularly in cases where the batching extends into the DDP stack.

I’d suggest posting a GitHub issue with all this great detail. The framework team may see it in the help category, and in any event we should track it as an issue.


#7

As promised, I tried using React instead of Blaze, and that seems to help…

  • Chrome is much faster
  • FF was reasonably fast before and after, though I think it’s faster after.
  • Safari was so-so before and may actually be worse under React; certainly it is the worst of the 3 when using React, while it was a close second to FF on Blaze.

I’ve added the “react” branch to my meteor-test repository with my latest code, which is back to just rendering all Test records (sans limit), up to 2k in my tests here.

Here is a short screencast inserting two consecutive batches of 1000 records. The first batch finishes in FF+Chrome in a few seconds, while Safari takes >10s. The second batch finishes in FF in ~4s, Chrome in ~6s, and my screencast times out with Safari >10s and still only ~halfway done.

Here is a screencast inserting 10 consecutive batches of 100 records, from 0 up to 1000 total records.

  • Chrome+FF mostly finish all batches ~instantly, which is a huge improvement over the Blaze version.
    • Curiously, FF’s first batch seems frozen/delayed for several seconds.
    • In a second test of this workload, I didn’t see this happen; some kind of transient issue, I guess.
  • Safari takes a few seconds for each batch of 100.
  • Firefox mostly seems to re-render exactly once for each batch, while Chrome renders twice (e.g. at 501 then at 600), and Safari renders many times during each batch.
    • It seems like the number of re-renders attempted may be the main cause of latency;
    • FF and Chrome behave here as if they are getting their updates in large batches.
    • React appears to be making Chrome interact with the DOM much more sanely.
      • Chrome was previously doing this same 500→501→600 pattern, but the 501 was taking many seconds to render.

Finally, here is a screencast of some more batches of 100, from 1000 total records up to about 1500.

  • Again, FF seems frozen out of the gate, as if its socket has shut down or some kind of fixed cost is being repaid.
    • As above, a repeat of this workload didn’t repeat this issue, but I thought I should mention it anyway.
  • Each browser exhibits familiar re-render patterns:
    • Chrome tends to go 1100→1101→1200, →1201→1300, etc.,
    • FF goes 1100→1200→1300,
    • Safari renders many intermediate snapshots.
  • Overall latency seems directly proportional to # of re-renders.

FWIW, here are a second set of screencasts of the same workloads as above, with each browser’s error console visible, to verify that no JS errors or other funny-business seems to be happening:

Responding quickly to the latest replies above:

Per the first question in my last post, is this relevant once the page has already loaded and the subscription has been marked as ready? In my tests, the subscription does not revert to an “un-ready” state when updates come over the wire to an already-loaded page.

So, unless I am misunderstanding, subscription-readiness is not relevant here, as it seems like you’ve acknowledged.

As background, my original goal here is to build an alternative to [the Spark web UI], but with the feature that updates to data are displayed in real-time, without having to refresh the page. A basic use case involves the user starting a Spark “job” that has 1000 “tasks” running in parallel on a cluster, and viewing the progress of those tasks via the web UI, optionally sorting the 1000-task table by any of its columns via the sortable JS library.

I’m not really interested in the argument that reactively displaying ~100KB of data in a 1000-row table in fewer than several seconds is outside the bounds of what Meteor should be able to do.

I am interested in discussing why DDP+Blaze, and to a lesser extent DDP+React, seem to take longer to do this than I’d hoped, and how “we” ( :wink: ) might be able to improve things.

@debergalis thanks, I’ll file a small issue on Github and point at this thread.

Thanks for all your help, again!


#8

@debergalis, @ryanwilliams

Existing GitHub issues:




#9

Hey - I’ve made a small tweak to ReactMeteorData - https://github.com/nathan-muir/meteor-test/tree/react-slightly-deferred - it makes it play a bit nicer in Safari for this use case.

It appears Safari is “flushing” after each message or something; So it renders row-by-row.

By delaying the forceUpdate just a little, more changes can happen in each batch.

Well - I’ve also done a version of your code that paginates (100 per page) - seems to be quite snappy in all browsers - https://github.com/nathan-muir/meteor-test/tree/react-paginated

The modified subscription takes a page number arg - I then look for both page and sub in the getMeteorData function. This means, if the page changes, we can check & wait for the new subscription to be ready.

There’s not one single problem here; there are different constraints when dealing with reactivity, and you need to design around them. Sometimes we get lucky, and Meteor-core or some package has done the work for us, sometimes we have to figure it out.

But we can’t assume that we can just build pages that would work fine in a Server-only Render-Once scenario, and have them work as reactive render-multiple-times-per-millisecond pages on the client - without proper consideration (like pagination, loading, or delayed/batching of changes).

Speaking of doing the work for us - there are some packages that handle all this reactive table stuff - which would be worth testing. https://atmospherejs.com/?q=table


#10

there are some packages that handle all this reactive table stuff

https://atmospherejs.com/aldeed/tabular promises to be a flexible solution (built around datatables.net) to the large data-set problem:

A Meteor package that creates reactive DataTables in an efficient way, allowing you to display the contents of enormous collections without impacting app performance.

Also makes use of arunoda’s subscription manager.


#11

Yeah, I’d like to see a version with tabular to see what effect that has… Interesting idea.