element-hq / element-web

A glossy Matrix collaboration client for the web.
https://element.io
GNU Affero General Public License v3.0
11.28k stars 2.03k forks source link

bundle.js is way too big and should progressively load to speed up initial launch #2498

Closed nolanlawson closed 1 week ago

nolanlawson commented 8 years ago

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:

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:

TheLarkInn commented 8 years ago

Wonder if this was webpack 1 or 2.

nolanlawson commented 8 years ago

@TheLarkInn Webpack 1

ara4n commented 8 years ago

@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.

nolanlawson commented 8 years ago

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. 🙂

MTRNord commented 6 years ago

Is #7391 related?

t3chguy commented 8 months ago

Worth noting, that matrix-react-sdk is the bulk of Element Web. It isn't much of an SDK, its more than Element Web is a skin atop the Matrix React SDK project.

A lot of the original analysis is outdated (not invaluable however)

Some things are gone, some new things are in.

image

The total gzipped bundle size is now 6.6MB

It is split significantly more than it used to be but the main bundle is still 1.85MB gzipped. There's also now a 2.57MB rust crypto wasm bundle and a rust wysiwyg wasm bundle at 0.82MB gzipped which make up the majority of the overall size.

draft-js is gone velocity.js is gone core-js is gone immutable is gone fbjs is gone q is gone

buffer and lodash are still around

I have a PR which uses React Suspense to async load some more dependencies like maplibre-gl which has taken the main bundle down to 1.38MB

t3chguy commented 1 week ago

Now that we've moved things around the code splitting works a lot better

image

The largest bundle is the rust crypto wasm blob which we have no control over