Fixing Your Meteor App’s Render Performance

Loren Sands-Ramshaw
Dec 8, 2015 · 9 min read

Once you get to a certain complexity in a Meteor app, things start slowing down. When you trigger changes in the view, for example when changing routes, it takes a while for the new stuff to appear on the page, and while you’re waiting, the page is unresponsive — you can’t scroll, tap, or swipe. It happens because Blaze, which is the part of Meteor that responds to browser events, runs your template helpers, puts together the HTML, and inserts/removes the HTML from the DOM, runs in the main thread, and when Javascript is running in the main thread, the browser is unable to update the page in response to your attempts to scroll. I’ve had to work a lot on the rendering speed of my complex mobile Meteor app, Parlay, and I learned a lot along the way ☺️ I even had the honor of it being analyzed by Paul Lewis. (Thanks Paul! You can follow and learn from him at @aerotwist.) If you’d like to check out the app on desktop, the URL is https://parlay.io.

Sacha asked me to take a look at the render performance of Telescope, his open-source news-upvoting Meteor app, so I took a look and gave a talk about it at the NYC Devshop. I try to explain things as I go along, and there are further notes below. Comment with questions, and I’ll update the post!

The main recommendation is to leave the header in the DOM when switching routes, since re-rendering the categories menu takes up most of the one-second route-change wait. Rendering the menu takes a while because it calls helpers that do many minimongo queries to determine the item count in each category and subcategory. In Iron Router, you can leave the header alone by using a layoutTemplate, in which you yield to the section that contains the rest of the page below the header. You can also do it yourself with a Template.dynamic.

Timeline notes

{{#if session showPost}} 
{{> post}}
{{/if}}
// in Chrome console
console.time('starting render'); Session.set('showPost',true);

The console.time will give you a “starting render” label in the timeline. Then look at the flame graph, find the bars for template helpers, and see which ones take a long time, or which ones are being called that don’t need to be called. For example, this is how I found that when you increase a cursor limit, eg when doing infinite scroll, all the helpers on the existing templates get rerun: meteor/issues/4960. Also, when you have the URL determine the data context for a route, and you change the URL without changing the route, the whole data context usually gets invalidated, which makes all your helpers rerun. The solution to this is the Blaze+ package.

Going further

Once you’ve stopped running the helpers that don’t need to run, and once you’ve optimized the helpers that you need but take a long time, and you still have bad load times, what can you do?

Compensate / distract with animations

When a user interacts with the page, you have 100ms with which to respond (see the RAIL performance model, or this great Udacity course). When that interaction triggers Blaze rendering a new template, it may be more than 100ms before that template shows up on the screen. So in the meantime, you have to do something else to the current page. Here are a couple examples:

'click #settings-menu-link': (e) ->
Waves.ripple e.currentTarget
Meteor.defer -> // equivalent to setTimeout 0
Router.go 'settings'

If you can’t think of an animation that fits well, and the delay is over a second, then at least give an indication to the user that the app isn’t frozen, so they don’t freak out trying to tap and swipe at an unresponsive screen—for example you can grey out the screen a little and display “loading” with a spinner. We should be doing this for example in our infinite scroll on the home screen feeds—once you scroll down far enough to see the spinner and the scroll event fires (which on iOS means the page has to finish scrolling), the render of the additional parlays is triggered, which on many phones takes a few seconds, which leaves the screen frozen, which means we should communicate to the user that they can’t interact with the app.

Wait for the data to arrive

Rendering a list of items piecemeal, as the data for those items arrives via DDP, feels like it can take longer to complete than if you wait for all of the items to arrive and then render the whole list at once. And while the former method shows something on the screen sooner, it’s not a good user experience, because scrolling is janky, because Blaze keeps taking up the main thread to render the newly-arriving list items. In our Cordova app, we wait until the relevant subscriptions are ready before starting the index render, and we wait for the index onRendered (when it’s finished rendering) before taking down the app’s launch screen.

Pre-render things

A common action in Parlay is going to /new to create a new parlay. However, it takes a couple seconds for the /new template to render. So instead of rendering /new when the route changes, we render it at the initial index page load (while the launch screen is up), and hide it until the route changes.

We use the percolate:momentum package to do CSS animations, with which you do something like:

{{#momentum plugin='css'}}
{{#if shouldShow}}
{{> foo}
{{/if}}
{{/momentum}}

Momentum notices when the foo template is toggled, and adds CSS classes so you can animate the template’s addition and removal. But if foo takes a long time to render, you’re going to notice a delay before the animation starts. The solution is to not use momentum, and instead start it out hidden with CSS:

{{> foo shown=false}}<template name="foo">
<div class="foo {{showClass}}"></div>
</template>
Template.foo.helpers
showClass: ->
unless this.shown
'hidden'

Which brings us to:

Leave it in the DOM

One option is to leave your rendered templates in the DOM, and don’t remove or re-render them — just hide them until you come back to that route. Here is Kadira’s experiment with doing that. A couple problems you may run into:

Off-DOM rendering

The same crashing-on-mobile problem happened to Rocket Chat, the open-source Slack clone built with Meteor, when they tried caching templates in the DOM: https://youtu.be/yzkId54vng8?t=5m16s

Their solution is off-DOM rendering, and here’s how it works:

room.dom = document.createElement 'div'# create a new instance of the room template
room.template = Blaze._TemplateWith { _id: rid }, ...
# render the template, inserting as a child to the div we
# created at top
Blaze.render room.template, room.dom
# Now room.dom is a virtual DOM tree with the room UI.
# room.dom is just a variable in Javascript memory – it is not
# in the DOM / in the browser page.
# when the user opens this chatroom, put room.dom in the DOM
mainNode = document.querySelector('.main-content')
mainNode.appendChild room.dom
# when the user leaves the chatroom, remove that tree from the DOM
mainNode.removeChild mainNode.children[0]
# keep room.dom around so that when the user returns to the room,
# we don't have to call Blaze.render again
# if you're in a browser tab that stays open for a long
# time, periodically clean up old rooms that haven't been
# used for a while:
# stop all the Tracker computations
Blaze.remove room.template
# remove from JS memory
delete room.dom
delete room.template

Manually calling Blaze.render (see RoomManager.coffee:getDomOfRoom), inserting that DOM tree into the page, and removing it from the page when the user leaves the room.

Non-reactively render

Another solution from Rocket Chat is non-reactive rendering, and I’ll let Gabriel explain it: https://youtu.be/yzkId54vng8?t=7m6s

Here’s the package: https://github.com/Konecty/meteor-nrr

Enter this in your browser console to see how many computations you have active:

Object.keys(Tracker._computations).length

For reference points, leaderboard has 52, the RocketChat demo has 600, the Telescope version I tested above has 13.5k, Parlay on desktop loads with 46k, and Gabriel’s recommended limit is 50k.

Too many computations is also a problem that Asana ran into with their Meteor-like Luna framework: https://blog.asana.com/2015/05/the-evolution-of-asanas-luna-framework/ (their equivalent is called an rvalue). It was one of the reasons they switched to React for Luna2.

Render fewer things

In Parlay, on index, we used to render 20 parlays on the friends and public feeds, and all of your own parlays. This worked perfectly fine on desktop, but either took forever or crashed on mobile browsers. So on mobile browsers, we only render four of your own parlays, and leave the rest until you either scroll down or swipe over to your friends or public feeds. While you could do a user agent check to see whether you’re on a mobile device, we’re actually doing something a little more complicated:

There are a number of things you may want to do differently on slower devices—not just render less, but maybe do fewer animations, subscribe to less data, or maintain as many computations. You may want to progressively change these things in a more dynamic way than just an isMobile check. There are low-resource desktops, and there’s a large range in mobile JS runtimes, from the latest iOS device to old Android devices (see Jeff Atwoods’ post on how bad the state of Android JS is). White- or blacklisting devices would be a messy solution. Instead, measure performance! Here is what we do on the very first page load in a new client’s browser:

Then for example, if the duration of a single-item render was over 200ms, render 4 items; if it was between 200ms and 50ms, render 8; and otherwise render 20.

Rewrite

And if all else fails, you can try rewriting in Angular or React, which generally have better render performance 😂 However, you will still run into some of the same problems (for instance Minimongo-calling helpers taking a while to run), and there are some cases in which React doesn’t perform as well as Blaze; for instance, it currently lacks the granular cursor changes that Blaze does with #each.

More info

The best general resource on render perf is http://jankfree.org/

Story of why MixMax switched one of their pages off of Meteor for performance.

If you use React, check out this article on React render performance.

On the non-JS side of render perf, check out another of my analyses: Emoji at scale: Render performance of CSS sprites vs individual images

And if you liked this post, please take a sec to tap the heart below, and maybe the follow button as well 😄

@lorendsr on Twitter

Parlay Engineering

How we build

Parlay Engineering

How we build

Loren Sands-Ramshaw

Written by

Freelancer and author of the @graphqlguide

Parlay Engineering

How we build