I've recently been working on improving the performance of a personal side project I've been working on, Conju-gator.com, a little app for practicing verb conjugations in Spanish.
The app is built in React with webpack as the bundling tool, the static files are served from Amazon's S3 with CloudFront content delivery network in front. When developing with React it is common to end up bundling all your source files and dependencies into one single JavaScript file called a bundle. The amount of JavaScript you serve is known to be a probable cause of performance issues, since the bundle can grow quickly as you pull in more and more dependencies.
Originally I didn't give much thought to performance as it seemed like a tiny project, with very few source files and not many dependencies, and I thought performance would be something that I wouldn't need to worry about until later.
However, the site does rely on a fairly large quantity of verb data to produce the training questions, which initially was a JSON file that I imported and bundled along with the source code, and thus could potentially cause performance problems at some point.
I decided to run Chrome's Lighthouse performance audit (a brilliant tool) and get a benchmark for how my site was doing and to my horror it scored 0% on the audit!
What I was doing wrong
The audit highlighted a few key areas where I could make improvements:
- Code was not minified
- JavaScript payload was excessive
- Non-essential CSS was not being deferred
- Files were not served with an efficient cache policy
- Files were not zipped with gzip or equivalent before serving
The final two points were issues that I needed to fix at the S3/CloudFront level since they are server settings. The solution involved adding metadata to the objects I uploaded to S3 to ensure they were served with a max-age Cache Control header, and that they could be served gzipped. With these two fixes my audit improved about 50%.
The issue of non-essential CSS being loaded too early when it could be deferred I ended up solving with Google Web Font Loader although I also came across other approaches to loading async CSS which may also have been useful. The CSS changes didn't make a big difference in the audit.
Webpack Improvements
The first two issues, however, are the ones I want to talk about as they have to do with bundle size. I was serving a 3000kb JavaScript bundle, and when you think that the recommended size is < 250kb, you can see how off the mark I was.
Firstly, my code was not minified, which was an easy mistake to fix as there is a webpack plugin that'll do the job for you, or if you're using webpack in production mode then minification comes by default!
That's another issue - I wasn't using production mode when building my bundle for production. A single line: mode: "production"
in my webpack configuration solved so many problems - it brought the bundle size down considerably by only including the parts of libraries that were needed for production, and also gave me minification for free. Webpack's guide to bundling for production is extremely clear and helpful and I wish I'd read it earlier!
Off the back of more research, I also decided to remove source mapping in production (the webpack guide suggests to keep it, for debugging purposes, but to use a lightweight version). Source mapping maintains a map from your bundled code to your original source files so that line numbers & file names in the console refer to your original files and not the bundle. However I wanted to cut down my bundle as much as possible so removed it completely and will bring it back if needed.
By using Webpack Bundle Analyser I was able to watch as my bundle size decreased, and see where its size was coming from.
When the analyzer showed me that my node_modules were now taking up a reasonable amount of space compared to my source code, and my whole bundle size in production was under 250kb, I was pretty happy.
Finally, I decided to remove the verb data from the bundle and fetch it asynchronously, although I'd already got to about 98% on the audit at this point and although it reduced my bundle size further it didn't give me any Lighthouse performance audit improvements.
Reflections
Looking back, the changes I made were quite straightforward and I feel foolish for not realising how bloated my bundle was in the first place. However, at the time it took me some solid hours of work to work through all my problems, and figure out the best solutions.
At one point, I thought "I wish I'd just used create-react-app in the first place!" since CRA will provide default webpack configurations which would have surely been optimised for production and included all the things I'd originally omitted, plus more.
However, the CRA webpack configuration is about 400+ lines long, which is one of the reasons I always shy away from using CRA in the first place. I like to know what my configuration is doing and be able to change it if I need to, and I've traditionally found the configuration of CRA apps hard to debug and maintain.
What are your thoughts? Do you prefer an opinionated/optimised configuration at the expense of customisability?
Have you had an experience of optimising performance on a single page React app?
Am I missing any more performance wins?
🙌