Justin Duke

How I cut my Webpack bundle size in half

In the fall, a young man’s fancy lightly turns to thoughts of front-end performance.

When I initially built out Buttondown, I was focused on two aspects above all else:

  1. It being built quickly.
  2. It working reasonably well.

Notably excluded from that list is performance. Buttondown isn’t a slow app, but it is a heavy one: the bundle size while developing is measured in megabytes, and there’s a non-trivial loading time for first-time users.

Now that the core feature base has stabilized and nothing is particularly in an “on fire” state, I wanted to turn my eye towards maintenance work, and a big piece of that was seeing what I could do to shrink that bundle.

Dramatization of Buttondown delivering its webpack bundle to hapless browsers. The bread is delicious, delicious JavaScript.

So, yeah. A hair under four megabytes. Sure, that’s unminified and unuglified, but it’s way too large for an app of Buttondown’s size. I resolved to cut that size in half, and here’s how: a combination of best practices and common sense front-end principles that I really should have adopted from the start.

0. Analyzing the Bundle

I knew the bundle was big, but I didn’t really know why it was big. Thankfully, there’s a tool for that! webpack-bundle-analyzer analyzes your webpack stats and spits out a tree-map visualizing what’s taking up so much space:

What the bundle looks like

Yeesh.

1. Ditch jQuery

“Do you really need jQuery” is such a common question that it has its own site, and in this case the answer was, uh, no. Especially because Vue makes it exceptionally easy to capture events and reference the DOM, which were the only two things I was actually using it for. Here’s what the diff looks like:

.eslintrc.js CHANGED
@@ -17,7 +17,6 @@ module.exports = {
17
17
'Urls': true,
18
18
'SITE_URL': true,
19
19
'ga': true,
20
- '$: true,
21
20
'StripeCheckout': true,
22
21
'STRIPE_PUBLIC_KEY': true,
23
22
'amplitude': true,
assets/components/DraftBody.vue CHANGED
@@ -19,6 +19,7 @@
19
19
@scroll="handleTextAreaScroll"
20
20
@mouseup="handleTextAreaResize"
21
21
v-model="body"
22
+ ref="input"
22
23
/>
23
24
<div
24
25
:class="{
@@ -37,6 +38,7 @@
37
38
:class="{'hidden': !showMarkdownPreview}"
38
39
@scroll="handlePreviewScroll"
39
40
v-html="compiledBody"
41
+ ref="preview"
40
42
/>
41
43
</div>
42
44
</div>
@@ -78,21 +80,15 @@
78
80
79
81
methods: {
80
82
handleTextAreaScroll() {
81
- const $textarea = $('.draft-body__input');
83
+ this.$refs.preview.scrollTop = this.$refs.input.scrollTop;
82
- const $preview = $('.draft-body__preview');
83
- $preview.scrollTop($textarea.scrollTop());
84
84
},
85
85
86
86
handlePreviewScroll() {
87
- const $textarea = $('.draft-body__input');
87
+ this.$refs.input.scrollTop = this.$refs.preview.scrollTop;
88
- const $preview = $('.draft-body__preview');
89
- $textarea.scrollTop($preview.scrollTop());
90
88
},
91
89
92
90
handleTextAreaResize() {
93
- const $textarea = $('.draft-body__input');
91
+ this.$refs.preview.style.height = this.$refs.input.offsetHeight;
94
- const $preview = $('.draft-body__preview');
95
- $preview.height($textarea.height());
96
92
},
97
93
98
94
startDrag(e) {
assets/screens/Share.vue CHANGED
@@ -26,7 +26,7 @@
26
26
action="https://jsfiddle.net/api/post/library/pure/"
27
27
>
28
28
<input type="hidden" name="html" :value="embedCode" />
29
- <input style="display: none" type="submit" />
29
+ <input id="jsFiddleSubmitButton" style="display: none" type="submit" />
30
30
<a
31
31
class="tiny"
32
32
href="#"
@@ -133,7 +133,7 @@ src="${this.newsletterUrl}?as_embed=true"
133
133
},
134
134
methods: {
135
135
submitForm() {
136
- $('.jsfiddle-form input[type="submit"]').click();
136
+ document.getElementById('jsFiddleSubmitButton').click();
137
137
},
138
138
},
139
139
head: {
package.json CHANGED
@@ -69,7 +69,6 @@
69
69
"inject-loader": "^2.0.1",
70
70
"jest": "^19.0.2",
71
71
"jest-vue-preprocessor": "^0.1.3",
72
- "jquery": "^3.1.1",
73
72
"karma": "^1.4.1",
74
73
"karma-coverage": "^1.1.1",
75
74
"karma-mocha": "^1.3.0",
webpack.config.js CHANGED
@@ -65,10 +65,6 @@ module.exports = {
65
65
66
66
plugins: [
67
67
new webpack.ProvidePlugin({
68
- $: 'jquery',
69
- jquery: 'jquery',
70
- 'window.jQuery': 'jquery',
71
- jQuery: 'jquery',
72
68
Promise: 'es6-promise-promise',
73
69
}),
74
70
new BundleTracker({ filename: './webpack-stats.json' }),

What the bundle looks like

This cleared up around 250 kilobytes., bringing the bundle down to 3.5 megabytes. Not a huge amount, but not bad.

2. Exclude moment locales.

Moment is a heavyweight solution to an evergreen problem: date handling that doesn’t suck. It ships with really strong locale support, which is great, but literally all I’m using it for is to format some UTC date-times, so including all of those locales seems unnecessary. Thankfully, I found a way to exclude them from webpack altogether.

webpack.config.js CHANGED
@@ -69,6 +69,12 @@ module.exports = {
69
69
}),
70
70
new BundleTracker({ filename: './webpack-stats.json' }),
71
71
new ExtractTextPlugin('[name].css'),
72
+
73
+ // The locales are non-trivially large and we don't use 'em for anything.
74
+ // So we ignore them:
75
+ // - https://github.com/moment/moment/issues/2373
76
+ // - https://stackoverflow.com/questions/25384360/how-to-prevent-moment-js-from-loading-locales-with-webpack/25426019#25426019
77
+ new webpack.IgnorePlugin(/^\.\/locale#x2F;, /moment#x2F;),
72
78
],
73
79
74
80
resolve: {

What the bundle looks like

This cleared up around 250 kilobytes, bringing the bundle down to 3.25 megabytes.

3. Lazy load zxcvbn

Zxcvbn is a very dope library by Dropbox that handles password validation. Buttondown uses it to, well, validate passwords:

Those prompts on the right come from a “score” generated by zxcvbn from 0-4.

However, it comes shipped with a 600KB list of frequently used passwords, which, uh, is non-trivially heavy. My original instinct was to fork the repository and shrink that list down, as others have done, but that didn’t seem very futureproof. Instead, I decided to try something that I’ve put off for a long time — implementing Webpack lazy loading.

It was definitely annoying to suss out some of the configuration requirements for lazy loading (notably the correct value of publicPath), but the final diff ended up being nice and compact. The actual code change is a little clumsy (and I’m not thrilled about having the actual implementation being so tightly coupled with webpack), but it’s hard to argue with the results.

.babelrc CHANGED
@@ -10,6 +10,7 @@
10
10
]
11
11
],
12
12
"plugins": [
13
+ "syntax-dynamic-import",
13
14
"transform-object-rest-spread",
14
15
"transform-runtime"
15
16
]
assets/components/PasswordValidator.vue CHANGED
@@ -13,7 +13,6 @@
13
13
14
14
<script>
15
15
import _ from 'lodash';
16
- import zxcvbn from 'zxcvbn';
17
16
18
17
export default {
19
18
props: {
@@ -36,6 +35,7 @@
36
35
37
36
watch: {
38
37
password: _.debounce(async function() {
38
+ const zxcvbn = await import(/* webpackChunkName: "zxcvbn" */ 'zxcvbn');
39
39
this.strength = zxcvbn(this.password).score;
40
40
this.checkedPassword = this.password;
41
41
}, 500),
webpack.config.js CHANGED
@@ -12,6 +12,8 @@ module.exports = {
12
12
output: {
13
13
path: process.env.NODE_ENV === 'production' ? path.resolve('./staticfiles/webpack_bundles/') : path.resolve('./assets/webpack_bundles/'),
14
14
filename: '[name]-[hash].js',
15
+ chunkFilename: '[name]-[hash].js',
16
+ publicPath: '/static/webpack_bundles/',
15
17
},
16
18
17
19
devtool: 'eval-source-map',
@@ -51,10 +53,11 @@ module.exports = {
51
53
options: {
52
54
cacheDirectory: true,
53
55
presets: [
54
- ['es2015', { 'modules': false }],
56
+ 'es2015',
55
57
'stage-2'
56
58
],
57
59
plugins: [
60
+ 'syntax-dynamic-import',
58
61
"transform-object-rest-spread",
59
62
"transform-runtime"
60
63
]

What the bundle looks like

This cleared up around 750 kilobytes, bringing the bundle down to 2.5 megabytes. We’re almost there!

4. Selectively import lodash

Lastly, I’ll close with perhaps the most canonical example of “a thing everyone tells me I should do for performance reasons but I don’t because I’m lazy”: replacing the full-on lodash import with selected ones.

This is was very easy (and boring) to do, and resulted in a very boring diff, so I’m only showing you one instance of it. Rest assured, the rest looked exactly like this:

assets/components/AccountEmailValidator.vue CHANGED
@@ -20,7 +20,7 @@
20
20
</span>
21
21
</template>
22
22
<script>
23
- import _ from 'lodash';
23
+ import debounce from 'lodash/debounce';
24
24
25
25
import { EmailValidationStatus } from 'types';
26
26
import validateEmail from 'services/validateemail';
@@ -38,7 +38,7 @@
38
38
},
39
39
40
40
watch: {
41
- email: .debounce(async function() {
41
+ email: debounce(async function() {
42
42
this.status = EmailValidationStatus.PENDING;
43
43
this.status = await validateEmail(this.email);
44
44
}, 500),

What the bundle looks like

This cleared up 450 kilobytes, bringing the bundle down to 2.08 megabytes. Not quite half, but close enough.

Next steps

Two megabytes is still a bunch (even if its unminified), and it’s larger than I want it to be, but I’m really happy with the results! I got to learn more about Webpack, improve the quality of the codebase, and the smaller bundle size has had a non-trivial impact on bounce rate.

There’s definitely some stuff I could do to shrink the code footprint even more:

  1. Remove extra fonts.
  2. Get rid of moment entirely and write a bespoke date management utility.
  3. Lazy-load flatpickr (it’s only being used in a couple places).

But those are projects for another day. Now it’s time for more features.

And, to end with another bread gif:

The cat is Chrome in this metaphor, I think.

Further reading

  1. Paul Irish’s perf audit of Reddit
  2. The Critical Request
  3. Why our website is faster than yours 1
  4. Optimizing your bundle size

  1. Classic case of “bad headline, good content”. [return]
Liked this post? You should subscribe to my newsletter and follow me on Twitter.
© 2017 Justin Duke • All rights reserved • I hope you have a nice day.