loading...

We reduced our vendor.js from 210kb to 16kb in about five minutes of work and ten lines of code

ben profile image Ben Halpern ・2 min read

Even though we strive for a minimal JavaScript load on dev.to, we had gotten lazy with our optimization. Our vendor.js file, which includes all the code from our external libraries, had ballooned to 210kb. The total size of the JavaScript we deliver had gotten up to about 250-300kb depending on context.

We load all JavaScript asynchronously and 210kb for this file is still good enough by most standards. So the user experience was not affected greatly, but it was silly to let this continue when it didn't need to be this way.

I decided to finally devote some brain power to the problem and the fixes wound up being pretty straightforward. I had personally hesitated to look into this because I was a bit unsure that it was the right approach. But after having done this, I feel pretty confident it was.

The one neat trick was loading these libraries using a technique called "Dynamic Import".

// before
import { bark } from "./dog";
bark("Hello World");

// after
import("./dog").then(({ bark }) => {
  bark("Hello World");
});
Enter fullscreen mode Exit fullscreen mode

This and other great techniques are outlined wonderfully in this post:

By doing this, a few seldom-used libraries will only get called when the user triggers an action in our code. Our vendor.js is down to about 16kb and our total JavaScript impact is about 65-75kb initially, and can creep up depending on context. I'm confident we could get that initial load under 25kb, but I think it would take longer than five minutes. As mentioned earlier JavaScript is deferred, so it never acts as a blocking resource.

This technique was already in place in one area of the code, but we had not been implementing it in these important big-win environments. By moving a few expensive libraries to this technique and also establishing the pattern more within our codebase, I believe we have set ourselves up to offer the most performant experience and avoid future creeps like this.

Here is the PR where the changes were implemented.

Happy coding.

Discussion

pic
Editor guide
Collapse
ben profile image
Ben Halpern Author

Hat tip to @nickytonline for first introducing this concept into the DEV codebase a while ago.

Thanks for a great post going over this stuff @goenning .

We're all making @addyosmani proud. 😄

Collapse
goenning profile image
Guilherme Oenning

Great stuff! If you keep looking, you'll find many more places you can apply this!

Chrome Code Coverage is also a great tool to find downloaded javascript/css that has not been used.

Collapse
nickytonline profile image
Nick Taylor (he/him)

Wasn't searching for the 🎩 tip, but I'll take it! It's still crazy how little effort it took to get that perf gain. 💪💯

Collapse
addyosmani profile image
Addy Osmani

So proud! :') Great work, Ben and team! These kinds of vendor bundle savings are awesome to see. Also, so good all of these are open-source for others to learn from.

Might be worth considering bundlesize for CI/PR JS budgets at some point :)

Collapse
nickytonline profile image
Nick Taylor (he/him)

Noice! For those interested, here's another PR in the codebase that uses a dynamic import... because perf!

github.com/thepracticaldev/dev.to/...

It's a good use of a dynamic import in this case because we only want to load the on-boarding flow if the user has never seen it.

Collapse
pavelloz profile image
Paweł Kowalski

You can make a second step, and load it when its needed (or not load when its not needed - i assume not every page needs it).

Simplified example for prism.js:

if ($q('code[class*="language-"]')) {
  import(/* webpackChunkName: "syntaxHighlighting" */ './js/syntaxHighlighting');
}

Notes:

  • chunkName makes it prettier than 0.fade4.js :)
  • $q - is an alias for document.querySelector
  • file is not exporting anything, just initializing, so there is no need to run .then()... ;)
  • if you have something that you want to prefetch/preload because its also an option: webpack.js.org/guides/code-splitti...
  • link above has also some links to help you understand webpack bundle and make better informed decisions: webpack.js.org/guides/code-splitti...
  • you can construct path to file to be imported dynamically, so you can have a function (simplified, naive, just for demo purposes):
const dynamicImport = p => import(`modules/${p}`).then(m => m.default())

And then call it from your js, when something happens.

If dev.to wasnt so heavy into react i could do some good with my webpack knowledge, but i dont want to get dirty with all the abstractions in there ;)

Collapse
bennypowers profile image
Benny Powers 🇮🇱🇨🇦

Unfortunately, Firefox has not yet implemented dynamic import. How do you do progressive enhancement for module browsers that don't implement import()?

Collapse
rhymes profile image
rhymes

TLDR; : with transpilation

dev.to uses webpacker which supports dynamic import (it also supports prefetch and preload). This in turn is supported through Babel (a transpiler), with @babel/plugin-syntax-dynamic-import

Collapse
bennypowers profile image
Thread Thread
rhymes profile image
rhymes

What do you mean?

Thread Thread
bennypowers profile image
Benny Powers 🇮🇱🇨🇦

well, in order to transpile import() you need a way to a) convert modules to scripts and b) dynamically load those scripts.

Sounds like Async Module Definition.

I did a half-hearted poke through the production code, and it looked more like cjs to me. Just curious what kind of 'modules' are actually serving down to clients.

Collapse
andrerpena profile image
André Pena

Great job Ben =)

Out of curiosity, where are the other 194KB? I would assume they are in another vendor chunk. Right?

Webpack 4 has the concept of initial and async chunks. Dynamic imports are automatically separated from your original bundle.js and are downloaded on demand by the Webpack runtime.

An interesting thing is that you can prefetch your async bundle. Prefetching is an ambiguous term but it means that the browser will download this resource with super low priority. This is cool because you can induce the browser to not block your render but download it as soon as possible in such a way that it will possibly be already there by the time it is needed. I didn't check the internals of how this works in Webpack 4 but there is a high change that the Webpack runtime will auto prefetch async bundles.

Collapse
ben profile image
Ben Halpern Author

The other 194 are in chunks that load when import is called within the code.

Some are quite deep in app logic and we really never want them for most visits. They are only called as necessary. We would maybe want to prefetch them once folks get close to where they would be hidden, but that's about it.

Collapse
fcpauldiaz profile image
Pablo Díaz Márquez

Is it possible to use async/await ?

Collapse
ben profile image
Ben Halpern Author

Yes, looks like it

  (async () => {
    const moduleSpecifier = './utils.mjs';
    const module = await import(moduleSpecifier)
    module.default();
    // → logs 'Hi from the default export!'
    module.doStuff();
    // → logs 'Doing stuff…'
  })();
Collapse
link2twenty profile image
Andrew Bone

I presume you could do something like this?

const moduleImport = async (loc, callback) => {
  const module = await import(loc);
  callback(module);
}

moduleImport("./dog", ({ bark }) => { bark("Hello World") });
Thread Thread
icatalina profile image
Ignacio Catalina

How is this better than just using a promise, as shown in the post?

Collapse
bennypowers profile image
Benny Powers 🇮🇱🇨🇦

you could even

  (async () => {
    const moduleSpecifier = './utils.mjs';
    const { default: utils, doStuff } = await import(moduleSpecifier)
    utils();
    // → logs 'Hi from the default export!'
    doStuff();
    // → logs 'Doing stuff…'
  })();

Which is similar to the static syntax

import { default as utils, doStuff } from './utils.js';
Collapse
manu profile image
Manu Lopez

Good job Ben! Hats off to you and @goenning for showing this technique.

Collapse
electrode profile image
Collapse
ben profile image
Collapse
nans profile image
Nans Dumortier

This is very nice ! I didn't know Dynamic import at all, and I'll definitely try it out !

Collapse
mustra1 profile image
Ivan Ružević Mustra

I did a talk about this subject. Great stuff.
github.com/iruzevic/presentations/...

Collapse
pierre profile image
Pierre-Henry Soria ✨

Great one! Thanks for sharing Ben :)

Collapse
chenge profile image
chenge

Import on Demand.

Collapse
ben profile image
Ben Halpern Author

I think this post by @quii is relevant to this discussion. I hope we can do a lot more to improve on this front.

Collapse
quii profile image
Chris James

This is cool, I haven't seen this technique before

Is there a chance it could make some features a bit slow when they're first used?

By doing this, a few seldom-used libraries will only get called when the user triggers an action in our code.

So if i click some button, it's now that it downloads, parses and executes the JS; which might be slow.

I guess it's all trade offs and from my point of view it seems like a good one.

Collapse
rhymes profile image
rhymes

So if i click some button, it's now that it downloads, parses and executes the JS; which might be slow.

Prefetching would be the next step but it requires more than 5 minutes:

The other 194 are in chunks that load when import is called within the code.

Some are quite deep in app logic and we really never want them for most visits. They are only called as necessary. We would maybe want to prefetch them once folks get close to where they would be hidden, but that's about it.

I guess it's all trade offs and from my point of view it seems like a good one.

Yeah, you still download it only one time