Element-web: bundle.js is way too big and should progressively load to speed up initial launch

Created on 23 Oct 2016  ยท  5Comments  ยท  Source: vector-im/element-web

Based on a tweetstorm from Alex Russell about the perf of Riot, I thought I'd look into your JS bundle and provide some guidance. Unfortunately I don't have any PRs to contribute because it's a _lot_ of code and I think you may need some significant refactoring to remove/defer the bigger dependencies, but there are also some small wins that seem easy enough to make.

Also, because y'all have been kind enough to make the code open-source, I figure that doing a public perf analysis could be instructive. :smiley:

High-level view

As Alex notes, there's far too much JavaScript on first load. Building the production version of Riot, I end up with two bundles: olm.js which is 438kB (128kB gzipped) and bundle.js which is 2.22MB (621kB gzipped).

The total bundle size (almost 3MB) is extremely large (although not the largest I've seen in a modern webapp, sad to say). Our first goal should be to isolate large dependencies so that we can trigger-load them using Webpack code splitting. Barring that, large dependencies should be removed entirely where applicable.

olm.js appears to be a cryptographic library which probably isn't needed on first load of the page. Seems like a good candidate to defer until later.

bundle.js is more complex, so let's analyze it using Webpack Visualizer. You'll need a stats.json file which you then upload to the Visualizer; you can generate it using stats-webpack-plugin (see branch).

The visualizer reports ungzipped/unminified sizes but it's a pretty good yardstick. Let's dive in.

bundle.js analysis

95% of the bundle size comes from dependencies:

screenshot 2016-10-22 17 17 51

The largest dependency by far is matrix-react-sdk:

screenshot 2016-10-22 17 19 39

matrix-react-sdk contains a lot of code, but the biggest offender is highlight.js. It seems we are pulling in all of highlight.js including all plugins for all languages.

screenshot 2016-10-22 17 59 39

The syntax highlighting for an obscure language called AutoIt takes up 1.9% of the total bundle size (!), Mathematica is 1.2%, SQF is 0.59%, and so on. I imagine most of these are languages that users will never need syntax highlighting for, so it's a shame that they're included on first load.

Looking inside of matrix-react-sdk, we can see where highlight.js is included (HtmlUtils.js):

screenshot 2016-10-22 17 23 01

This file also contains a reference to emojione, which is another 4.2% of the bundle size:

screenshot 2016-10-22 17 23 48

Using require.ensure() in this file to trigger-load just emojione (4.2%) and highlight-js (12%) would already trim 16.2% from the bundle size.

Also inside of matrix-react-sdk is a very large lib folder which contains components, views, dialogs, etc.:

screenshot 2016-10-22 17 26 36

This seems to be a case of lots of little files adding up. Unfortunately there's no easy fix, but here are a few strategies you could try:

  1. Use Rollup to bundle all the tiny files into one big file, which in my experience should save around 5% of the total size (assuming no dead code, which Rollup would further eliminate).
  2. Trigger-load all of matrix-react-sdk. Unfortunately it seems to be used in a lot of places, so I don't know how practical that is, but since it's about 1/3rd of the total bundle size, this is potentially a huge win.
  3. Better yet, trigger-load all the views only when those views are shown. This is more work than the Rollup solution or the "trigger-load the whole SDK solution", but would result in the biggest wins since each view would only require the code it needed.

React and other deps

React takes up about 11% of the bundle size. Unfortunately without server-rendering, I'm not sure how you could remove this because it seems pretty integral to the app:

screenshot 2016-10-22 17 29 57

matrix-js-sdk takes up another 11% of the size; seems to contain a lot of crypto logic that could possibly be deferred. This also seems to be including its own crypto library (different from core Node crypto); maybe WebCrypto could be leveraged here?

screenshot 2016-10-22 17 38 18

Other deps that may be worth cutting/deferring:

  • draft-js โ€“ 5.1% of total bundle size, could be trigger-loaded until we're in edit mode
  • velocity.js โ€“ 4.5% โ€“ maybe use CSS animations or vanilla FLIP animations instead?
  • core-js โ€“ย 3.6% โ€“ you might not be using all of these polyfills; maybe consider refactoring so that each ES6 dependency is a separate dependency so you can manage them separately? e.g. Promise/Symbol/Array.includes/etc. can all be used via separate polyfills instead of needing to include one big Babel polyfill. Unfortunately this would require grepping your codebase to see where you may be using ES6 features. Even more easily, you could just cut support for older browsers.
  • immutable โ€“ 2.5% โ€“ this library provides a lot of convenience and some runtime perf wins for React, but it's a lot of code.
  • lodash โ€“ 2.2% โ€“ consider lodash-webpack-plugin or e.g. require('lodash/methodName') to avoid pulling in all of Lodash. Unfortunately lodash is a transitive dependency of a lot of other dependencies, so it's hard to tell which one is pulling in the most Lodash code.
  • fbjs โ€“ 1.7% โ€“ this is a utility library pulled in by draft-js, so another good reason to drop/defer it.
  • q โ€“ 1.1% โ€“ consider native Promises; this library seems to only be used in a couple places, and you're already including an es6-promise polyfill anyway via the above-mentioned core-js.
  • buffer โ€“ 0.87% and 0.77% โ€“ somehow two different copies seem to have been included; may I recommend Blobs and possibly blob-util instead? Also this seems to be only used in one place in your own codebase โ€“ VectorConferenceHandler.js uses it to convert to base64 when you could use btoa() instead.

Core app code itself

The core code for vector-web, inside of the lib/ folder, is 4.6% of the total bundle size. This seems to be mostly views and components, so again it could be slightly optimized by using Rollup or optimized in a more targeted way by trigger-loading each view based on the page it corresponds to.

Conclusion

I haven't had a lot of time to deeply analyze the codebase, but a few high-level observations stick out to me.

First off, the codebase seems to have been written in a style that would be familiar to Node veterans, but unfortunately Node conventions do not always work well when applied to the browser. In particular, exclusively using require() and bundling everything into one large file has reached the breaking point here, and certain large dependencies that are only used in sub-sections of the app (e.g. emojione and highlight.js) should ideally be split out using require.ensure() (or the non-Webpack equivalent in case you plan on switching from Webpack).

I'd advise breaking HtmlUtils.js out from matrix-react-sdk into a separate module so that it can be easily trigger-loaded from vector-web. In general, creating multiple small modules should increase your flexibility here. If the modularity becomes too difficult to manage as multiple repos, then I'd recommend a monorepo (e.g. using Lerna or Alle).

If you're eager to support both Node and browser versions of the code, I'd recommend leveraging the "browser" field in package.json to swap out code for the browser vs code for Node. E.g. to avoid bundling buffer you could use the built-in Buffer in the Node version of your VectorConferenceHandler.js file and Blob in another (assuming you need that particular file to work both in Node and in the browser). I see you're already using the "browser" trick in matrix-js-sdk; I would keep using it in more parts of the codebase to reduce unnecessary browser code.

Overall, there are lots of potential perf wins here, but most of them involve splitting up code and using less code. Using simple tools like the Webpack Visualizer and npm ls can help you figure out where large dependencies are getting included and how you can avoid them.

I know a lot of this is "easier said than done," and I'm sorry I don't have any code to contribute, but I hope this analysis was useful! Good luck on making Riot faster. :smiley:

bug p2 performance

All 5 comments

Wonder if this was webpack 1 or 2.

@TheLarkInn Webpack 1

@nolanlawson huge thanks for the very comprehensive analysis here - the help and amount of time you've put into this really is enormously appreciated.

The reason we haven't yet got as far as doing _any_ optimisation of the initial load time of the app is simply that we are working through major perf issues on the rest of the app first, some of which are pretty catastrophic - i.e. all of https://github.com/vector-im/vector-web/issues?utf8=โœ“&q=is%3Aissue%20is%3Aopen%20label%3Aperformance; the issue that @dbaron hit (some combination of #1969, #1619 or #2499); the fact that loading the initial state from Matrix can literally take minutes on big accounts (#1846) etc. Only one of these relates to the time taken to load initial JS (#126), and we've just had to prioritise progressive JS loading below all the other perf issues for now, given practically it simply isn't on the critical path of the day-to-day performance of the app.

Just to clarify: currently the time taken to display the login page of the app because of all the horrible unnecessary front-loaded JS is simply dwarfed by the amount of time it takes to (say) display the room directory, or load your chat history once you've logged in, or the cumulative time spent waiting to change rooms. As the most common use case for the app is to launch it once and then leave it running in a background tab for days or weeks, this hopefully explains the current priorities.

That said, if folks who are particularly concerned about initial app load time wanted to blitz the dependencies with async loads and incremental loading it really would be appreciated. And if nothing else, we'll get to this once the other perf fires have been put out.

That makes a lot of sense! I totally understand where this bloat comes from and have seen it myself on plenty of projects that were otherwise very well-architected. I agree that if most of your users are on desktop and are mostly complaining about in-app performance rather than first-load performance, then you've got the right priorities โ€“ tackle first-load after you've put out the other fires.

The hardest thing I've found about performance is that it's very difficult to "apply" after the project has already been built; usually you have to think about it from the get-go or end up needing to do huge refactors to fix creeping perf problems. If you'd like some more thoughts on this I strongly recommend Designing for Performance which is probably the best book written on the subject. ๐Ÿ™‚

Is #7391 related?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

turt2live picture turt2live  ยท  3Comments

bagage picture bagage  ยท  3Comments

lukebarnard1 picture lukebarnard1  ยท  3Comments

MurzNN picture MurzNN  ยท  3Comments

grahamperrin picture grahamperrin  ยท  3Comments