Optimizing Om Apps

Performance is very important for a prototyping tool, particularly one built on the web. This forces me to spend a lot of time thinking about browser performance for Precursor. Our goal at Precursor is to make collaborating on ideas effortless. If things don't behave the way users expect, there's a risk of pushing them backwards—retreating towards alternatives that are more familiar, like pen and paper or whiteboards.

Our front end is built with Om and React; both come with excellent performance right out of the box. React is well-known for minimizing the amount of slow DOM updates with their virtual DOM diffing. Om improves on that by leveraging fast comparisons of Closure's persistent data structures.

As good as they are, the defaults weren't good enough for us. I noticed that Precursor felt sluggish when too many people were drawing in the same document together, and when drawing on mobile browsers. I traced most of these problems to components rendering unnecessarily. For example, the chat component would re-render every time a new drawing was created.

To get a sense for performance bottlenecks, you generally want to see:

  • How many times a component is mounted/unmounted
  • How many times a component is rendered (to determine when it's being rendered unnecessarily)
  • How long each component takes to render

I built some instrumentation tools to display this information in an actionable format while interacting with the app. These tools have been invaluable while optimizing Precursor, and we've decided to open-source them so anyone can include them in their own Om projects.

I'm calling the library Om-i (pronounced "Oh, my!"), which is short for Om-instrumentation. You can find Om-i on GitHub and you can also play with a live version on Precursor by hitting Ctrl+Shift+Alt+J.

How it works

Om lets you specify custom handlers for React's component lifecycle methods. First, we track mount times by wrapping Om's default componentWillMount and componentDidMount methods. Then in componentWillMount, we associate the start time with the component's React id, and calculate the mount time when componentDidMount fires. We also do the same with the update lifecycle methods to get render times.

(defn wrap-will-mount "Tracks last call time of componentWillMount for each component, then calls the original componentWillMount." [f] (fn [] (this-as this (swap! component-stats update-in [(utils/react-id this)] merge {:display-name ((aget this "getDisplayName")) :last-will-mount (time/now)}) (.call f this)))) (defn wrap-did-mount "Tracks last call time of componentDidMount for each component and updates the render times (using start time provided by wrap-will-mount), then calls the original componentDidMount." [f] (fn [] (this-as this (swap! component-stats update-in [(utils/react-id this)] (fn [stats] (let [now (time/now)] (-> stats (assoc :last-did-mount now) (update-in [:mount-ms] (fnil conj []) (if (and (:last-will-mount stats) (time/after? now (:last-will-mount stats))) (time/in-millis (time/interval (:last-will-mount stats) now)) 0)))))) (.call f this))))

Every mount and update gets stored to keep track of average and maximum render times.

Check it out on GitHub.