Skip to content

Speed up page loads by over 10x, reduce JavaScript bundle transfers by over 18x #7889

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 36 commits into
base: master
Choose a base branch
from

Conversation

purfectliterature
Copy link
Contributor

@purfectliterature purfectliterature commented Apr 18, 2025

Coursemology's JavaScript bundle sizes have been very huge and it contributes to slow loading times, especially in high-latency environments, such as during exams with many concurrent requests to our NGINX server, where our assets are currently hosted on. Coursemology's rendering strategy is client-side rendering (CSR), so JavaScript has to be fetched and rehydrated before it can make any data requests to our Rails server. Therefore, slow JavaScript loads will exacerbate overall page loading times.

Note

TL;DR: See Benchmarks below for results.

Current bundle outlook

These are the chunks always required on almost all page loads, sorted by size. There are also other chunks, but they aren't as important and are excluded here for brevity.

No Chunk Size (MB)
1 vendors~AuthenticatedApp-4d33a1d53c01477ead01.js 3.38
2 AuthenticatedApp-00139d8230435dbd0af8.js 3.35
3 vendors~coursemology-816b79e887033561fc61.js 2.14
4 vendors~UnauthenticatedApp-7537252b633f2a61a050.js 1.57
5 coursemology-133494ef361c4a39795d.js 0.83 (828.53 kB)
6 default~AuthenticatedApp~UnauthenticatedApp-77270f00c736abe47c89.js 0.46 (459.13 kB)
7 UnauthenticatedApp-a72b5e08f21b2a4d2eae.js 0.07 (71.43 kB)
  • Chunks 5 and 3 are always loaded because they are the main entry points.
  • Chunks 7, 4, and 6 are loaded on pages accessible only when a user isn't signed in.
  • Chunks 2, 1, and 6 are loaded on pages accessible only when a user is signed in.
Scenario Chunks Total JavaScript bundles loaded (MB)
Signed in 5, 3, 2, 1, 6 10.20
Not signed in 5, 3, 7, 4, 6 5.07

This is the results of the current bundle analysis.

before

Optimisation strategies

CKEditor

CKEditor contributes the largest (1.26 MB) and it's bundled twice. It's probably bundled into UnauthenticatedApp even when there are no pages that renders a rich text editor because webpack doesn't know if its import has side effects, thus can't tree-shake it.

The custom build has been optimised upstream and while I'm at it, I upgraded it from v40 to v45. Essentially, I directly import the distributed bundles and CSS of the extensions directly so nothing unused is bundled. This reduced the CKEditor chunk size from 1.26 MB to 1.13 MB.

Most importantly, CKEditor is now lazy-loaded so only pages that need it will load it on demand. A skeleton will appear when the bundle loads, but I think this is an acceptable trade-off.

Route-based code-splitting

This is the root cause of our enormous loading times. Our bundle grows proportionally to the features we develop; there's no way around this. However, what we should be optimising isn't just reducing bundle sizes, rather, reducing bundle sizes on each user journey. There's no reason why a user who visits the Announcements page needs to wait 5 seconds for CKEditor or Ace chunks to also be fetched.

This PR implements lazy loading the components for all front-end routes we have in Coursemology. This is known as route-based code-splitting. This means that our bundle will be sliced into smaller chunks and when a user visits a page, they fetch only what they need. This naturally will increase our total built bundle size (in fact, this PR increased it from 12.68 MB to 13.79 MB), but this doesn't matter; what does is the amount of JavaScript transferred on each route. See Benchmarks below.

Route-based code-splitting also ensures that if we do have pages/components that are unoptimised, written poorly, or just inevitably large, other pages aren't "penalised" for this.

This results in pages having entry point chunks ranging from 3 kB to 145 kB, which is still an acceptable bundle size, compared to having to load the entire 3.35 MB AuthenticatedApp.

Locale splitting

The current yarn build:translations which uses script/aggregate-translations.js pools the translations for all locales into one huge locales.json (364.34 kB) which is then bundled with our app. This PR ensures that only the locale for the set language is fetched at any time.

Important

Another huge contributor is how we use fixed ids for our Descriptor translation messages. This is unnecessary, not recommended, error-prone, hard to maintain, and contributes to huge chunk sizes because the whole id string is parsed. Ideally, we should just never set ids and let babel-plugin-formatjs generate ids by hashing the Descriptor. I don't think this is the time to undo existing codes, but from now on, please stop setting ids.

Lazy-loading assessment questions' answer components

SubmissionEditIndex at courses/:courseId/assessments/:assessmentId/submissions/:submissionId/edit now lazy-loads the answer components for different question types. This way, students who does only programming questions need not load the chunks for other questions' answers. This reduced the SubmissionEditIndex entry point chunk from 261.54 kB to 141.24 kB.

jQuery and jQuery UI

We're only using jQuery and jQuery UI (122.29 kB combined) for the reordering of achievements in the table in AchievementReordering. This PR removes the implicit global availability of jQuery and ensures both libraries are loaded on demand only when a user decides to reorder the achievements.

Ace

While Ace's mode scripts aren't huge, we are loading them all over the place. This PR ensures that modes are loaded on demand when we need them, e.g., only load ace-builds/src-noconflict/mode-r when we are loading R programming answers.

Stylesheets minification

This PR minimises our restricted CSS and SCSS imports with cssnano via PostCSS before they are imported and bundled to their respective JavaScript chunks. I specifically disabled CSS source maps since they aren't necessary and reduced bundle sizes by about ~300 kB.

Other optimisations

  1. Trim moment-timezone data to only from 2014, when Coursemology has its first commit (reduced ~100 kB)
  2. Replaced lodash relative imports with lodash-es absolute imports (reduced ~68 kB)
  3. Replaced webfontloader with a simple CSS @import (reduced ~50 kB)
  4. Removed unused intl library (reduced ~92 kB)
  5. Remove react-player's other player chunks (e.g., Mixcloud, Twitch, etc.) and keep only YouTube (reduced ~43 kB)
  6. Use SWC for more aggressive minification (reduced ~50 kB)
  7. Automatically optimise images and SVGs (reduced ~4 MB, though technically this isn't JavaScript)

Important

I also removed PropTypes from production builds, but this isn't perfect since some still leaks through. We need to eventually remove it since they are removed and ignored in React 19 and has been deprecated since April 2017.

Compression

The final touch is adding gzip and Brotli compressions which brings down the total bundle size to 3.55 MB. The NGINX server needs to be updated to serve gzip and Brotli bundles with gzip_static on; and brotli_static on;.

Caution

Generally, never think that compression is an acceptable answer to large bundle sizes, especially if they are bundles that we write and can control. For chunks that are just inevitably large, e.g., CKEditor, Ace, react-dom, it's okay to rely on compression. But generally, always think about how your codes will be bundled as you're writing them and/or how they & existing ones can be optimised.

We should be solving the root cause of large bundle sizes before relying on compression. Compression is just an icing on the cake.

Final bundle analysis

There are way more chunks generated, in fact, it increased from 54 to 391 files in this PR (1,066 files including the gzip and Brotli files). The total static bundle size is 23.2 MB (from 19.6 MB) in this PR.

after

Benchmarks

Benchmarks are done under these conditions.

  • Google Chrome 135.0.7049.96 (arm64) on macOS Sequoia 15.4 (24E248)
  • MacBook Pro (14-inch, 2021), Apple M1 Max (10-core CPU, 32-core GPU), 64 GB RAM
  • Production builds from 0427e57 (before) and f135616 (after), source maps excluded
  • Apps are hosted and proxied locally with dirt-cheap-rocket version 1.1.0.
  • Rails server are running on development mode, but the results reported are averages of 5 runs on each route.
  • Every run is reloaded with "Empty cache and hard reload" option on Chrome DevTools and done synchronously after the page finishes and Rails server idles.

No throttling

JavaScript transferred (MB)

These routes were chosen because they represent the simplest, average, and most resource-heavy pages, respectively.

URL Before (MB) After, parsed (MB) Diff After, gzipped (MB) Diff
/ (signed out) 5.30 2.00 2.7x (-62%) 0.46 11.5x (-91%)
courses/1821/videos?tab=986 10.60 3.30 3.2x (-69%) 0.78 13.5x (-93%)
courses/1821/assessments/33160/submissions/884159/edit 10.60 4.80 2.2x (-55%) 1.20 8.8x (-89%)

Time to finish (seconds)

URL Before (s) After, parsed (s) Diff After, gzipped (s) Diff
/ (signed out) 0.99 0.46 2.1x (-53%) 0.26 3.8x (-73%)
courses/1821/videos?tab=986 2.76 1.67 1.7x (-39%) 1.33 2.1x (-52%)
courses/1821/assessments/33160/submissions/884159/edit 2.64 1.78 1.5x (-33%) 1.17 2.3x (-56%)

Fast 4G network throttling

Time to finish (seconds)

URL Before (s) After, parsed (s) Diff After, gzipped (s) Diff
/ (signed out) 7.18 3.69 2.0x (-49%) 2.12 3.4x (-70%)
courses/1821/videos?tab=986 13.68 5.91 2.3x (-57%) 3.65 3.8x (-73%)
courses/1821/assessments/33160/submissions/884159/edit 13.87 7.45 1.9x (-46%) 1.30 10.7x (-91%)

Miscellaneous page loads, no throttling

These are additional data I gathered on some other pages, just to get a sense of how much the app has improved with this PR.

JavaScript transferred (MB)

URL Before (MB) After, parsed (MB) Diff After, gzipped (MB) Diff
courses/2881/assessments 10.60 3.70 2.9x (-65%) 0.88 12.1x (-92%)
courses/2881/assessments/73714 10.60 2.80 3.8x (-74%) 0.66 16.1x (-94%)
courses/1821/assessments/33208/submissions 10.60 2.70 3.9x (-75%) 0.58 18.2x (-95%)

Time to finish (seconds)

URL Before (s) After, parsed (s) Diff After, gzipped (s) Diff
courses/2881/assessments 2.87 1.78 1.6x (-38%) 1.47 2.0x (-49%)
courses/2881/assessments/73714 3.07 1.82 1.7x (-41%) 1.55 2.0x (-50%)
courses/1821/assessments/33208/submissions 2.53 1.47 1.7x (-42%) 1.23 2.1x (-51%)

Bundle analysis, side-by-side

Before After
before after
Before After
Total bundle size (MB) 12.68 13.79
Total static assets size (MB) 19.60 23.20
Chunks 54 391
Static assets 54 1,066
Average JavaScript transferred 5 – 10 MB 2 – 5 MB (460 kB – 1.2 MB gzipped)

@purfectliterature purfectliterature added Performance JavaScript Pull requests that update JavaScript code labels Apr 18, 2025
@purfectliterature purfectliterature self-assigned this Apr 18, 2025
@purfectliterature purfectliterature changed the title Reduce page loads by up to 10x, reduce JavaScript bundle sizes by up to 18x Speed up page loads by up to 10x, reduce JavaScript bundle transfers by up to 18x Apr 18, 2025
@purfectliterature purfectliterature force-pushed the phillmont/chunks branch 2 times, most recently from fc5f437 to f135616 Compare April 19, 2025 05:14
@purfectliterature purfectliterature changed the title Speed up page loads by up to 10x, reduce JavaScript bundle transfers by up to 18x Speed up page loads by over 10x, reduce JavaScript bundle transfers by over 18x Apr 19, 2025
@purfectliterature purfectliterature force-pushed the phillmont/chunks branch 3 times, most recently from 643ec29 to 53d9081 Compare April 19, 2025 12:36
@purfectliterature purfectliterature marked this pull request as ready for review April 19, 2025 12:52
}),
new CompressionPlugin({
algorithm: 'brotliCompress',
test: /\.(js|css|html|svg|png)$/,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We added gzip compression on nginx side here:

https://github.com/Coursemology/dockerfiles/pull/225/files#diff-4f23d6e69886291438c3e60045efe49ee183e5100e1d9cd461ab6f92f48cfcf6R47

When this is merged, which content type(s) should we remove?

Copy link
Contributor Author

@purfectliterature purfectliterature Apr 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can replace it to this.

gzip_types text/plain text/css text/html text/xml application/json application/xml application/xml+rss image/vnd.microsoft.icon image/x-icon;

And add gzip_static on;.

This means we gzip anything that webpack doesn't already. But we only serve things webpack build. So we probably won't need on-demand gzipping (oops!). Though, compressing ahead of time is probably better for performance.

Honestly, I didn't confirm if the files listed in test are actually compressed in the final build 😂 Just because test says so doesn't mean they are compressed because webpack only deals with files that webpack can pick up, i.e., imported. Hence why I left some types in gzip_types just in case webpack didn't compress them because I haven't checked. I just removed types that I know webpack will compress, i.e., JavaScript files and SVGs.

Either you can help check (sorry for troubling you!) or I can do it once I return from leave next week.

Alternatively, we could update the NGINX config to what I shared above. When I return, I'll check, then update either the NGINX or webpack config as needed in separate PRs. Then we can test & enjoy this 10x improvement ASAP.

Copy link
Contributor Author

@purfectliterature purfectliterature Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest we remove on-demand gzipping from our NGINX config. All files that matter are already compressed by webpack. SVGs and PNGs aren't compressed probably because they were picked up at a different build phase. Some files weren't compressed because they are already small enough, and that compressing will end up creating a bigger file.

So, just have this.

gzip_static on;
brotli_static on;

I don’t think it's worth it to have on-demand gzipping because it still puts load on NGINX. Ideally, we should be throwing these assets to S3 and have NGINX only proxy these requests to S3, so it doesn't incur any memory or disk bandwidths. The only concern is uncompressed SVGs and PNGs, but these are already optimised by ImageMinimizerPlugin.

This also reduces our reliance on NGINX, making it easy for us to move to CloudFront (if we ever decide to) and letting NGINX only focus on balancing Rails server loads.


If we really need to have on-demand gzipping for some reasons, I suggest we keep this gzip_types.

gzip_types text/plain text/css text/xml application/json application/xml application/xml+rss image/svg+xml image/vnd.microsoft.icon image/x-icon;

This effectively only compresses SVGs. We don't have the other files.

@@ -1,6 +1,5 @@
import { DependencyList, useCallback, useEffect } from 'react';
import type { DebouncedFunc } from 'lodash';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it not worth to require the individual functions (e.g. lodash.debounce), or is that taken care of by lodash-es?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not aware of doing lodash.debounce or such syntax for other functions.

Are you asking if we could just not replace with lodash-es and instead just changing the imports to absolute ones with lodash.debounce?

From what I've seen, it seems like lodash and lodash-es only differ in their imports. When I installed lodash after lodash-es, yarn.lock doesn't change.

Copy link
Contributor Author

@purfectliterature purfectliterature Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I remember. A differentiating reason on why I ended up with lodash-es is because its package.json has sideEffects: false that webpack can use for tree-shaking. lodash doesn't have this key.

@@ -82,7 +82,7 @@ class VideoPlayer extends Component {
UNSAFE_componentWillMount() {
if (VideoPlayer.ReactPlayer !== undefined) return; // Already loaded

import(/* webpackChunkName: "video" */ 'react-player').then(
import(/* webpackChunkName: "video" */ 'react-player/youtube').then(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would happen to this component if the URL isn't a youtube URL?

As of writing this comment, all of our URLs saved in production point to youtube, but in theory we have a whitelist of supported video sites to link to
https://github.com/Coursemology/coursemology2/blob/master/app/helpers/application_html_formatters_helper.rb#L86-L96

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the URL isn't a YouTube one, then it will just render an empty frame. I decided to do this despite knowing our back-end whitelists other video providers because the New Video form's placeholder explicitly says "YouTube".

In fact, we probably should tighten the whitelist on our back-end because with that placeholder, our business logic really is supporting YouTube only.

Copy link
Contributor Author

@purfectliterature purfectliterature Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the whitelist for other video providers and skipped their tests in 7941198 to synchronise our client & server codes with business requirements. We can always add them back once we decide to have them as part of our business requirements.

We have been, and currently is supporting only YouTube videos.

@purfectliterature purfectliterature force-pushed the phillmont/chunks branch 2 times, most recently from d62f00c to e10c918 Compare April 28, 2025 06:36
We do this because from the business logic point-of-view, Coursemology
only supports YouTube. The New Video form's placeholder specifies that,
and the production database only has YouTube URLs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
JavaScript Pull requests that update JavaScript code Performance
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants