DRAFT: JavaScript style for optimal size
Bundle size bloat is often excused as a necessary trade-off to get features or DX but when I actually look at the code of websites or libraries I use, I often see bloat that is completely avoidable and boils down to marginal stylistic choices.
Writing JavaScript in different style can have a huge impact - you will see that some constructs can be 2-3 times larger than their smaller equivalents.
I thought this is a common knowledge, but apparently it isn't, so here's a description of JavaScript style that optimizes for size well.
Basics π
If you're just starting out with bundle size optimizations, this is probably not the right place to start.
This only makes sense to follow if you already do all the obvious stuff - use compression, minify your code, remove unused dependencies and dead code, hash and cache the bundle, code split heavy pages, etc.
On the other hand, if you're starting a new project, setting up these style rules might pay dividends in the future.
TL;DR π
- ban
function
,switch
,enum
,class
andthis
keywords - minimize mentions of object properties and globals
- minimize repetition in general by using variables and free functions
TL;DR is very simple but requires quite a bit of explanation to justify. Let's dive in.
The big idea π
If you want to optimize for size, you have to understand what comes out of your build process. Fortunately, JS is shipped as text, so you can just look at your output bundle in the text editor. Most bundlers emit size info (even gzipped one, which is helpful), so pick your biggest offenders and look through the code.
If you compare the output with your sources, you should notice that some constructs end up much smaller than others. This is the key observation from which most of the rules below follow.
The most important transforms that happen during the build are:
- removing whitespace, comments and dead code
- renaming identifiers
- replacing if-else with ternaries
- grouping variable declarations and expressions using comma operator
- shortening arrow functions to a single expression form
These optimizations are mostly done by minifier (e.g. Terser), but there's some overlap with other parts of the pipeline, so I'll often just use "bundler" as an umbrella term.
Bundler can make your code significantly smaller, but it has to be conservative to not break semantics. You can help it by avoiding certain problematic constructs. Notably, if your bundle is dominated by keywords and identifiers, especially the original ones from your source code, you have a lot of room for improvement. Optimal bundle is dominated by punctuation.
Let's start with some simple cases.
Extract variables π
Minifiers gain a lot by renaming identifiers to short, often single letter names. This means, that if you can extract some repeated expression into a variable, you can save a lot of space. Variables are cheap and declarations can be grouped, so the cost of let
or const
keyword is often minimal.
This is pretty obvious for repeated expressions, but I want to highlight a few cases where the benefit might not be immediately obvious.
Repeated strings π
With stringly typed API like this:
const state = loadState('saved-webpage-state')
saveState('saved-webpage-state', state)
// minified version (59 bytes)
const s=l('saved-webpage-state');t('saved-webpage-state',s)
We can extract a constant, and even though the source code looks bigger, it minifies much better, because we can shorten the identifier:
const SAVED_WEBPAGE_STATE = 'saved-webpage-state'
const state = loadState(SAVED_WEBPAGE_STATE)
saveState(SAVED_WEBPAGE_STATE, state)
// minified version (43 bytes)
const K='saved-webpage-state',s=l(K);t(K,s)
You encounter a lot of cases like this in the web world - event names, element names, CSS classes, union discriminants. We often use literals for these, because they look terser in the source code, but bundler won't extract them into a variable for you by default, so you pay the price in bundle size.
Repeated property access π
Due to the JavaScript dynamic nature, it's very difficult to predict when it's safe to rename a property inside an object, so minifiers don't do it. Basically, the only things that can be safely renamed are local items (variables, parameters, functions, classes).
This is why it's beneficial to avoid repeated access of object properties.
This piece of code doesn't minify well, because object properties are repeated:
const here = long.chain.that.ends.here
const there = long.chain.that.ends.there
// minified version (62 bytes)
const a=long.chain.that.ends.here,b=long.chain.that.ends.there
but if you extract the target object into a variable, it's much better:
const target = long.chain.that.ends
const here = target.here
const there = target.there
// minified version (47 bytes)
const t=long.chain.that.ends,a=t.here,b=t.there
This is also a good practice for performance in general. 1
Avoid object properties if you can π
This one easily follows from the previous one. If you can avoid naming object properties or even using objects, it's very often good for size. This is a hard one, because coding without objects can be pretty messy. I don't think it makes sense to follow this rule strictly, because it has some maintenance cost, but you can avoid it in many cases.
When you need to create a lot of objects that you'd naturally specify with a large literal:
const options = [
{label: 'One', value: 1, disabled: false},
{label: 'Two', value: 2, disabled: false},
{label: 'Three', value: 3, disabled: true}
]
// minified version (113 bytes)
const o=[{label:'One',value:1,disabled:!1},{label:'Two',value:2,disabled:!1},{label:'Three',value:3,disabled:!0}]
In cases like this, it's useful to make factory functions that return objects:
const makeOption = (label, value, disabled) => ({label, value, disabled})
const options = [
makeOption('One', 1, false),
makeOption('Two', 2, false),
makeOption('Three', 3, true),
]
// minimized version (95 bytes)
const m=(l,v,d)=>({label:l,value:v,disabled:d}),o=[m('One',1,!1),m('Two',2,!1),m('Three',3,!0)]
One typical problematic case is the practice of using an object with named properties as a parameter to a function, instead of a list of parameters:
const fun = ({first, second}) => first + second
fun({
first: 1,
second: 2
})
// minified version (55 bytes)
const f=({first:a,second:b})=>a+b;f({first:1,second:2})
You can save a lot by avoiding it, especially if you have a lot of callsites:
const fun = (first, second) => first + second
fun(1, 2)
// minified version (25 bytes)
const f=(a,b)=>a+b;f(1,2)
The object parameter practice is terrible for code size, so it's good to avoid it unless it has some tremendous benefits, which is typically the case when the function has a large number of optional arguments (for example, the useQuery
function in popular @tanstack/query
library).
One thing you can do in that case is to add the object as a last parameter for optional properties, and keep the required parameters as normal ones.
This is how some DOM functions are structured, for example addEventListener
:
/* type and listener is required */
function addEventListener(type, listener, options = {}) { /* ... */ }
If you decide the object property is worth the cost, consider using some minimal naming convention, to at least reduce the impact. I'm a bit sad that @tanstack/query
didn't rename their most common properties queryKey
and queryFn
to just key
and fn
. That would save a lot of bytes on all websites that use it over the world.
Similar can be said about Vue for picking .value
instead of .val
or even .v
for their reactivity primitives. At the time of writing this paragraph, .value
is present 1429 times in my current codebase, so that's at least 3kb overhead just from a naming convention (compared to .val
). To be fair, Vue made huge improvements here by introducing composition API, so .value
is really just a nitpick on otherwise pretty good track record.
Of course, you have to balance this with maintenance cost, but some of these choices are pretty arbitrary and picking a shorter name wouldn't make much of a difference.
If you use these APIs a lot and don't control them, it sometimes helps to wrap them in a function that maps normal arguments into the original object version, at least for the most common cases:
const useSimpleQuery = (queryKey, queryFn) => useQuery({queryKey, queryFn})
// minified version (40 bytes)
const s=(k,f)=>q({queryKey:k,queryFn:f})
This is also useful for frequently used object methods. You can turn them into plain function, operating on the arguments. e.g.
const startsWith = (haystack, needle) => haystack.startsWith(needle)
In both cases, this makes the callsites more minify-able, which helps if there's a lot of them.
Prefer arrow functions π
If you target ES6, it's better to use arrow functions for a few reasons
- They are defined with a
const
orlet
variable, which means they can be grouped together with other variable definitions without additional keyword - They can be very often transformed into a single expression, avoiding braces and
return
keyword - Single parameter ones can avoid parenthesis
The best case comparison looks something like this:
function identity(thing) {
return thing
}
const result = identity(1)
// minified version (35 bytes)
function i(t){return t}const r=i(1)
While arrow function version is much shorter:
const identity = thing => thing
const result = identity(1)
// minified version (19 bytes)
const i=t=>t,r=i(1)
In ES6, an object method can sometimes be cheaper (because it avoids both function
and =>
), but plain function is practically always cheaper as arrow function.
Avoid switch π
switch
is not worth much attention as it's not so common, but if you decide to use it (maybe to avoid long if
cascade), keep in mind that it has a high cost compared to if
and else
.
if
is good because minifier can often transform it into a ternary, which is very small:
let value = getValue()
let stringValue
if (value === 1) {
stringValue = 'one'
} else if (value === 2) {
stringValue = 'two'
} else if (value === 3) {
stringValue = 'three'
} else {
stringValue = 'other'
}
// minified version (57 bytes)
let v=g(),s=v===1?'one':v===2?'two':v===3?'three':'other'
switch
cannot be transformed like that, so your output will be dominated by case:
and break
statements + switch
keyword.
let value = getValue()
let stringValue
switch (value) {
case 1:
stringValue = 'one'
break
case 2:
stringValue = 'two'
break
case 3:
stringValue = 'three'
break
default:
stringValue = 'other'
}
// minified version (105 bytes)
let v=g(),s;switch(v){case 1:s='one';break;case 2:s='two';break;case 3:s='three';break;default:s='other'}
If you don't count the common code, the switch version is more than twice as long as the if
version.
All in all, switch
is rarely worth it and I don't use it very much.
Don't use classes π
Classes have a number of problems:
- lot of long and unavoidable keywords (
class
,constructor
,this
,return
) - every property or method access has to be prefixed with
this.
- class properties and method names cannot be minified 2
- class definitions cannot be grouped like variable definitions
For example:
class Counter {
constructor() {
this.count = 0
}
increment() {
this.count++
}
}
// minified version (67 bytes)
class Counter{constructor(){this.count=0}increment(){this.count++}}
In some cases, minifier can shorten the class name, but that doesn't help all that much. You can see that apart from the whitespace, the minified class definition is almost the same as the original one.
Thankfully, there's an alternative, which avoids many of the above issues: the old school module pattern. Instead of using a class, you create a function that returns an object literal, with all the class properties:
const createCounter = () => {
let count = 0
return {
increment() {
count++
}
}
}
// minified version (46 bytes)
const c=()=>{let a=0;return{increment(){a++}}}
Apart from the fact that it avoids many semantic class
issues (especially confusing this
binding), it has a number of size advantages, too.
- properties can be accessed directly, so there's no
this.
prefix in your code - private properties and methods don't need to be put on the object, so their names will be minified
- this also applies to all internal method calls, if you extract methods into variables
- No
constructor
keyword - You can use arrow functions to avoid
return
keyword and braces in methods that return a value - If you use arrow function for the definition, it can be grouped with other variable definitions, so you save the
const
orlet
keyword
Needless to say that it's not exactly equivalent construct, and sometimes people use classes for other performance reasons.
Another option is to skip the methods altogether and just use plain functions. This is the best for code size, but looses encapsulation:
const createCounter = () => {
return {
count: 0
}
}
const increment = (counter) => counter.count++
// minified version (38 bytes)
const c=()=>({count:0}),i=c=>c.count++
A big benefit of this approach is that unused methods can be eliminated by the bundler, which is not possible with both previous options. This can sometimes be a big problem - for example tanstack/query
uses a lot of classes internally, and consequently, my bundle contains a ton of dead code from it. In fact, in my current project, most of the code from this library is dead and the majority of my dead code is from this library.
I'd also suspect it's the best one for performance overall, because no closures and dynamic method binding is involved, but I haven't measured it so that's just a guess on my part.
Don't use typescript enums π
Again, there's already many reasons to avoid them, TypeScript advocates often discourage using them anyway, but once you see the compiled output, it becomes immediately clear that it's just not worth it at all. Variant names are not minified and enum definition contains bidirectional mapping between names and values, even if you only use one direction.
For a simple enum like this:
enum Animal {
Cat,
Dog
}
You get this:
let Animal;
(function (Animal) {
Animal[Animal["Cat"] = 0] = "Cat";
Animal[Animal["Dog"] = 1] = "Dog";
})(Animal || (Animal = {}));
// minified javascript (66 bytes)
let a;!(function(a){a[a.Cat=0]="Cat";a[a.Dog=1]="Dog"})(a||(a={}))
You can use a simple alternative that retains some of the type safety compared to plain constants:
type Animal = number & { readonly __tag: unique symbol }
const AnimalCat = 0 as Animal
const AnimalDog = 1 as Animal
// minified javascript (11 bytes)
let a=0,b=1
Creating this type requires an explicit cast, which makes potentially problematic places visible in the code.
If you want the type to be even more strict, appending one more union with | { readonly __tag: unique symbol }
will turn arithmetic on it into a type error.
This is what const enums should compile to, but they often don't, so I don't use them either.
Be careful with optional chaining π
Optional chaining would be amazing for bundle size, if it was widely supported. Some people already ship it, but others (including me) don't have that luxury 3. In that case, it has to be transpiled, which generates a ton of code.
let r=o?.property
// transpiled and minified version (44 bytes)
let r=o===null||o===void 0?void 0:o.proparty
In most cases, and especially if you use typescript, you can simply use &&
operator, which is much smaller:
// minified version (19 bytes)
let r=o&&o.property
This has a different semantics, but it's equivalent in most cases. Optional chaining was created precisely because this shortcut is problematic, but to be fair, I practically never encounter cases where it matters. Needless to say that I probably avoid them by specific coding style and especially by using TypeScript, which rejects most of the problematic cases (notably the one where o === true
).
This advice can be applied to other transpiled features as well. Depending on your target, this could be ??
, async/await
, generators, private properties and various other things.
Strings (minor) π
If you have a lot of strings, it can be beneficial to decompose them into pieces and concatenate them.
const listUrl = '/articles/list'
const createUrl = '/articles/create'
const popularUrl = '/articles/popular'
//minified version (67 bytes)
const l='/articles/list',c='/articles/create',p='/articles/popular'
const prefix = '/articles/'
const listUrl = prefix + 'list'
const createUrl = prefix + 'create'
const popularUrl = prefix + 'popular'
//minified version (58 bytes)
const p='/articles/',l=p+'list',c=p+'create',p=p+'popular'
To be honest, part of me cries whenever I have to do this, because it often makes the code very ugly, but it can help a lot when there are long repeated substrings.
Template strings are sometimes longer than string concatenation. This applies to prefixes and suffixes:
// minified template string (34 bytes)
const u=`https://example.com/${p}`
// minified concatentaion (32 bytes)
const u='https://example.com/'+p
In the middle of the string, template string is one character shorter.
const u=`https://example.com/${p}/api`
const u='https://example.com/'+p+'/api'
At this point we are just chasing bytes, though. I wouldn't worry about this too much, unless you're already at the limit of what you can do otherwise. Also, I think there might be some Babel plugin for doing that automatically.
Argument variable hack π
// todo should I remove this section??
Sometimes the arrow function rule clashes with the extract variable rule. When you have an arrow function with a single statement, which contains multiple expressions, extracting the variable requires you to add braces and a return keyword:
const getNestedValue = (obj) => obj && obj.nested ? obj.nested.value : null
// Extracted:
const getNestedValue = (obj) => {
const nested = obj && obj.nested;
return nested ? nested.value : null;
}
There's one obscure technique you can use to avoid braces/return and the const keyword, which is to use a new function argument as variable:
const getNestedValue = (obj, nested = obj && obj.nested) => nested ? nested.value : null
I don't recommend using this technique very much, because it can break when the caller uses the argument, but it's a useful trick to know about for some special cases. For example, I used this technique in vite's prefetching code here to reduce its size. This piece of code is injected into every chunk that uses dynamic imports and doesn't go through the normal minification process, so it's important to keep it small. It's also only called internally in generated code, so a hack like this acceptable.
Another option is to use IIFE:
const getNestedValue = (obj) => ((nested) => nested ? nested.value : null)(obj && obj.nested)
I don't use this one too much either, it makes the code pretty confusing.
Does this matter when we have compression? π
// todo should I remove this section?
Compression is absolutely necessary, it reduces the size by factor of 3 to 4. If you follow the style I described above, the compression ratio will get worse, because most of these techniques are "compressive" in nature, but the compressed size will still improve.
Just note that it fluctuates around optimum over time. It correlates with uncompressed size, but it's noisy, so it's not a good measure for individual edits.
Compression is only helpful for network transfer, but JS size also affects subsequent processing and even execution. This is probably less true for changes that are mostly syntactic (like changing function to arrow function), but more true for changes that correlate with the amount of code the engine has to emit (like deduplicating property accesses and expressions or removing enums).
If you profile a page load, you'll see that "compile code" takes a significant part of the initial JS processing, and if you look at heap snapshot, "compiled code" and various related data structures are usually pretty high in the list. JS heap also contains the whole source code, those are usually the biggest items in the strings section.
Based on some very rough page load measurements I did on my machine, JS processing is in the ballpark of 300 nanoseconds per byte. This will vary a lot and will probably be much bigger for mobile devices. 6 microseconds per byte on low-end device, based on measurements in this article
Conclusions π
There's probably a room for a language (possibly some restricted subset of JavaScript) that would allow us to make more size optimizations without restricting the coding style as much (we already use some attributes for bundlers, but those are pretty limited at the moment).
It would also be nice to have some ESLint preset for these rules, they are quite simple. I'd do that if I could finally understand how it's supposed to be properly configured.
Footnotes π
// TODO reorder based on how they appear in the article)
You might think that JavaScript engine is so smart these days, that it can optimize the repeated field access, but that's often not the case. Fields in JavaScript can be defined by getters, which can execute arbitrary code with side effects that the engine has to preserve. When the engine generates the code initially, it often doesn't know the exact shape of the object and whether the field is defined by a getter, so it can't do this optimization and has to generate code for each access individually - it can optimize the field access later, but it's starting out from already pessimized baseline.
You might also think - who'd be so crazy to add side effects to getters? But this is, in fact, very common. Frameworks like Vue, Solid or Mobx use proxies and getters to implement their reactivity system. They intercept property access, because they have to register a dependency when the getter is called - which is a side effect that has to be preserved for the reactivity system to trigger computation correctly in the future. This is a global concern that the engine can't reason about very much, so the optimization is often not possible.
Crazy assumption, right? Isn't it just bonkers that somebody can call delete window['localStorage']
and replace it with something else?
The problem with optional chaining and other language level features is that if the browser doesn't support it, it can't even parse the code, so in SPA architectures that rely a lot on JavaScript, everything breaks, and you often don't even get to know about it - even your error reporter doesn't compile. Other features (e.g. Array.flat
) are easier to handle, because just a specific call can fail, but the rest of the code can still operate.
This is technically not true for private properties, but those still require this.#
prefix, so it's not that big of an improvement. And the improvement goes out of the window if you target below ES22 and have to transpile them.