Fixing Your Meteor App’s Render Performance

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

  • I use Chrome Canary because its DevTools usually has better features than regular Chrome.
  • I’m zooming in/out with the scroll wheel and panning by dragging, but there are also keyboard shortcuts.
  • Looking at the function names in the flame graph is usually only intelligible when running the app in dev mode, when the JS isn’t minified.
  • Flame graph bar colors correspond with files, so if you find one bar with Template.foo.helpers.bar, you can look for other bars with the same color to find other foo-template helpers.
  • The index page is mainly taken up by going through the rows of posts. For analyzing that, I would isolate just a single row, doing something like:
{{#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:

  • Waves effect: On the Parlay index profile tab, you have a list of your own parlays. Tapping the list header triggers Blaze to render a dropdown of list filter options. On some devices, there’s a noticeable delay before the dropdown appears. To compensate, we added the material design waves animation to provide feedback to the user, letting them know that when they tapped the list header, the app received their input. One thing to note is that sometimes Blaze blocks the animation from happening (because it’s keeping the browser busy running JS, so it doesn’t have time to do the animation). In those cases you can wait before triggering Blaze:
'click #settings-menu-link': (e) ->
Waves.ripple e.currentTarget
Meteor.defer -> // equivalent to setTimeout 0
Router.go 'settings'
  • Opening profiles: When you tap a user’s avatar, an overlay appears with that user’s profile. It takes up to a few seconds for their profile to render, so we have a long period of time in which we need to distract them. In this case, I chose to translate and scale up the avatar, moving it to the position at which their high-res avatar appears as part of their profile. Make sure to use compositor-only properties when animating (like transform) to ensure visual smoothness. For a full property list, see http://csstriggers.com/

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:

  • Things can slow down when you have a huge number of DOM nodes.
  • Memory usage increases, which on phones can crash the browser or the containing Cordova app. For example in Parlay, while we do leave index and /new in the DOM, we don’t leave the other pages, even though there is a noticeable delay when navigating to for instance /friends or /notifications. When we tried leaving more pages in the DOM, the app crashed.

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:

  • wait for the browser to be idle (in the future, we’ll hopefully see support for requestIdleCallback, which will make this easier / more reliable)
  • start = Date.now();
  • render a complex template (in our case, the template that displays each parlay feed item)
  • in the template’s onRendered: end = Date.now(); localStorage.setItem(‘duration’, end - start);

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