Extreme Dead Code Elimination
Part 1: Separate Desktop & Mobile Builds
TL;DR
What is dead code?
Dead code can be described as any piece of code which will not get executed or is not reachable. However there are tools like Terser, which are used to identify such pieces of code and remove them. As an example consider the following code block
In the above example, if we replace __MOBILE__
with true
on line 18 and process it with terser [with mangle: false
; just so that we get a readable output], we’ll get the following result:
Scroll down to the bottom of this article [or click here] to find a codesandbox embed, where you can play around with this example. Try changing the input code or the
global_defs
value [try makingglobal_defs.__MOBILE__ = false
] to see how the output changes.
Notice that terser removed the unused code branch [line 23–27 in the original example] & it also removed the constant declarations on [line 16] and [line 9–13]. This is because terser was statically able to identify that if(true)
will always run.
Terser though couldn’t remove the unused imports, which can be done by webpack’s
[or any other good bundler] tree shaking
. And the final code can be reduced to the following [line 1–6 removed]:
Isn’t that awesome. This means so much less code being shipped to users browser. But if everything is already taken care of, why are you reading this article? Well, turns out that things are not this easy.
There are two problems
- Order of Execution [Tree shaking then terser]:
In most webpack configurations terser runs after webpack has already done tree shaking process. This creates a problem, since until terser has done it’s work webpack won’t know that the imports are useless [these imports become useless only after terser is done processing]. Hence the imports in the above example won’t be tree shaken by webpack, therefore, the final output will still contain those unused imports. - Static Identification of dead code:
How do we statically identify unreachable code. In a typical production code we might not have constants for things like__MOBILE__
, rather we might be dependent on user-agent to determine things like that
Let’s discuss each of these two problems in detail.
Problem 1: Order Of Execution [Tree Shaking then terser]
Webpack runs TerserJsPlugin in production
mode. But the order of their execution is: Tree shaking followed by terser’s minification. This is inefficient.
Let’s look at the sample code snippet above once again. If webpack were to directly attempt tree shaking on the above code. It won’t be able to identify the imports of ComponentA, ComponentB, ComponentC, SomeWrapper to be unused. Because it was terser which was removing the dead code and hence making those imports unused.
Solution? Run terser before webpack. In our case we ended up creating a custom webpack loader like so:
We ran terser on all our .js/.jsx/.ts/.tsx
files before webpack could get a chance to process them. Therefore, till the time webpack’s tree shaking started, it was working on a piece of code which was:
And hence, webpack could easily tree shake the unused imports. [Well obviously webpack needs to be configured properly to do that, check out webpack’s tree shaking guide]
There’s a catch here though. You’ll still want to run terser again after webpack [like it traditionally does]. Because webpack also adds a lot of scaffolding code which you will want to minify. But, that means increased build time. Therefore in the first run of terser we disable most of the optimisations [which will help reduce the time terser takes] and just enable the options[compress]
which help us do dead code elimination.
Problem 2: Static Identification of Dead Code
This problem is much more complicated. Why? Well because terser needs to know __MOBILE__
will be false/true
at the build time. So the very intuitive solution is to make 2 builds. One for desktop and other for mobile. and somehow define the __MOBILE__
variable at build time.
A very easy solution for that would be something like webpack’s DefinePlugin. But, since we are running terser before webpack could even get a chance, this won’t help us. Instead we could use terser’s global_defs
option. like so:
This works very similar to webpack’s DefinePlugin. Terser will replace all usages of __MOBILE__
with true
and do dead code elimination.
We can create 2 builds. and change the value of __MOBILE__
accordingly. And, in this case your mobile builds will benefit from dead code elimination and tree shaking.
- mobile.js — to target the mobile users
- desktop.js — to target the desktop users
Simple enough, right? Well yes, if you are just doing client side rendering.
If you are into SSR though, the problem is far from solved.
Should we create 2 builds for server as well? and then run 2 servers as well, one for desktop other for mobile. Seems like too much effort, and a lot of infra as well.
Wouldn’t it be nice to have just 1 single server build and 2 client builds? Because ultimately it’s the users’ performance that we want to increase. It really doesn’t matter if our server has unused code.
How to solve this then? Let’s consider the following code:
We want client-mobile code to become:
and our client-desktop code to become:
And what if our server side code could become something like:
Won’t this solve all our problems? Our client builds are optimised and our single server build has a function call which will be resolved at runtime [figure how? let’s come to this later]. This way a single server build will be able to serve both mobile and desktop users.
If we can:
- transform our code like this
- make
__isMobile
function somehow work properly
we’ll be so happy. Let’s solve both of these.
Sub Problem 1: Transform code
We created a babel plugin. Which does the following:
- Find all Identifiers named
__MOBILE__
and replace them with__isMobile
function call - Add an import for
__isMobile
fromutils/isMobile
Here’s how a simpler version of that babel plugin looks. [the actual one is a bit more generic and handles a few edge cases, but this version should be enough for the purpose of this article]
You can use astexplorer.net to write/debug your babel plugins. And refer to plugin handbook for documentation on how to write one
Sub Problem 2: make the __isMobile function work properly
While initially reading this you’ll think what’s the problem here? This should be simple.
We can read the user-agent, parse it [maybe using npm packages like express-useragent] and you are done.
Well the problem is you can only read the user-agent from the request
object. And while the babel plugin can replace __MOBILE__
with __isMobile()
. We still don’t have the access to the request object. And without the access to request
we won’t be able to determine the device correctly.
Well we were truly stuck. That’s when we stumbled upon a npm-package called cls-hooked [not going into how this package works as that can be an article in itself]. This was a solution to all our problems. Using this package like so:
Add the ‘expressMiddleware
’ in your express middleware chain, just after express-useragent
middleware [so that req.useragent
is set]
That’s all. This will ensure that your user-agent information is stored in a context [which is specific to a request]. Therefore, whenever a request comes in from a desktop device a call to __isMobile
will return false and vice versa.
Pheww!! That was long. But hey’ this works and works well. While the example here is just for desktop/mobile. This approach can be generic and might to customised according to different use-cases.
This approach along with many others has helped us at Housing.com to build beautiful experiences for our users.
Thanks for reading!
This is a first in the series of articles on what we have been doing to improve web performance at Housing.
Check out the other articles of this series: Part 2. Part 3 coming soon!
A shout out to everyone who made this possible: sachin agrawal, Naman Saini, Dhruvil Patel, Nikhil Verma
Below is the codesandbox embed for you to play around with terser. If this doesn’t work for some reason try clicking here