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:
- It being built quickly.
- 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 +0 -1
- assets/components/DraftBody.vue +5 -9
- assets/screens/Share.vue +2 -2
- package.json +0 -1
- webpack.config.js +0 -4
.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
: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
42
44
@@ -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
29
-
29
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 +6 -0
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
76
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 +1 -0
- assets/components/PasswordValidator.vue +1 -1
- webpack.config.js +4 -1
.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