Now that optional chaining has reached stage 3, it's time for a reevaluation.
A little more than a year ago, we decided to go ahead and start using @babel/plugin-proposal-optional-chaining
. As usual with babel plugins, the primary reason was developer experience. "It will make our lives easier".
And it did. It still does. I see it being used everywhere throughout our codebase.
In react componentDidUpdate
:
componentDidUpdate(prevProps) {
if (
this.props.image?.type !== prevProps.image?.type ||
this.props.image?.orientation !== prevProps.image?.orientation
) {
// ...
}
}
And in render functions:
function CommentButtons({ user }) {
return (
<div>
<Button disabled={user?.can?.edit}>edit</Button>
<Button disabled={user?.can?.delete}>delete</Button>
<Button disabled={user?.can?.reply}>reply</Button>
</div>
)
}
It does look nice. It's easy to understand what's going on. Yet, it does come with a cost. And we, or at least I, highly underestimated this. The costs are there, both in performance and in bundle size.
Performance
Let's first talk about the performance. Because that's not what concerns me most.
The performance cost is there if optional chaining is being overused. Don't guard all your properties. Only guard the unknowns. It's safe to make assumptions of existence if you're dealing with your own code.
That being said, We aren't iterating our own render function 65 million times in a second. So even while the performance hit can be up to 45%. It can still be negligible in production environments. For those wondering, here is the jsperf
. Please don't attach to much value to that.
Let's move on.
Bundle size
The CommentButtons
component posted above, for example, contains 244
bytes of written code, which is transpiled into 1.000
bytes. A factor 4 larger.
Because it's our own code, we can safely assume that whenever the user
prop is not undefined
, it also has the can
property. If it wouldn't be enforceable by the backend. It would be enforceable by the frontend. A parent component, or the place where we call the API.
Anyway, we can reduce the transpiled byte size to 477
bytes, by rewriting that component to remove the optional chaining
. We are not even assuming the existence of can
here, we default it to an empty object instead.
function CommentButtons({ user }) {
const can = user ? user.can : {};
return (
<div>
<Button disabled={can.edit}>edit</Button>
<Button disabled={can.delete}>delete</Button>
<Button disabled={can.reply}>reply</Button>
</div>
)
}
I realize this is an extreme example. But I see code quite similar to this in the wild. We developers just love our productivity tools. And if there is a babel plugin that makes something easier, than why not use it?
I'm not saying to not use the optional chaining at all. I still love using it. I'm asking you to remember that it does come at a cost. For example, try to not use a fallback for the same property twice within a single method:
var canEdit = user?.can?.edit;
var canDelete = user?.can?.delete;
// transpiles to:
"use strict";
var _user, _user$can, _user2, _user2$can;
var canEdit =
(_user = user) === null || _user === void 0
? void 0
: (_user$can = _user.can) === null || _user$can === void 0
? void 0
: _user$can.edit;
var canDelete =
(_user2 = user) === null || _user2 === void 0
? void 0
: (_user2$can = _user2.can) === null || _user2$can === void 0
? void 0
: _user2$can.delete;
We can easily reduce that, by only checking the user.can
property once:
var can = user?.can || {};
var canEdit = can.edit;
var canDelete = can.delete;
// transpiles to:
"use strict";
var _user;
var can =
((_user = user) === null || _user === void 0 ? void 0 : _user.can) || {};
var canEdit = can.edit;
var canDelete = can.delete;
And unless your first optional operator is nested somewhere, it might be worth it to take that last step, and do avoid the optional operator at all:
var can = user && user.can || {};
var canEdit = can.edit;
var canDelete = can.delete;
// transpiles to:
"use strict";
var can = (user && user.can) || {};
var canEdit = can.edit;
var canDelete = can.delete;
I hope this makes my point. I do realize that gzip can remove some of the overhead, as it's quite good at compressing repeating patterns like === void 0
and === null
. But even with gzip, the costs of optional chaining are there. Please remember it, as we will be stuck to using the babel transpiler for quite some time. Even now it's stage 3, it will not land in every browser that we need to support in a very short term.
I'll still keep using optional chaining. Albeit less fanatical.
đź‘‹ I'm Stephan, and I'm building updrafts.app. Feel free to follow me on Twitter.
Top comments (3)
This is a fair point, however I feel like the title should be "The Cost of Using A Babel's Polyfill for Optional Chaining", as this really isn't about the cost of the concept of optional chaining itself. Using pretty much any polyfill is going to decrease your performance and create a much larger bundle size. I have tested this out with Typescript using async code targetting ES3 to ES6. The ES3 output was way larger and the performance was much slower than the ES6 version.
You're right. It's definitely the polyfill causing this. In the jsperf link I've posted, you can also see that I did compare it with
idx
. And both have this issue. Idx does keep the transpiled code a bit smaller though.We could create something very smart by using proxies and getters, but that will have a serious performance impact in return.
Unfortunately, it will take a long time before we don't need the babel transform any more (or any other polyfill). So at least for now, the issue is quite real. Even though the real cause is the transpiler and not the syntax itself.
Great article, thanks! Also for the JSPerf experiment! I ran it a few times with different browsers/devices and it seemed to me there's already some optimization work going on o/ (I imagine it gets some push after proposals arrive stage 3)
On an i5 mac air I confirmed your finding that Chrome, also/therefore Opera & Edge - but curiously not Safari - are still ~42% slower.
However both in Safari and FF performance hit is negligible (sometimes even better) on your I'd say not so trivial test case.
Also interestingly I could confirm in an iPhone 6S that Firefox Mobile is indeed 50% faster for optional chaining and Chrome/Safari “consistently”* faster by 5% (*three measurements)
About bundle hit IMO it’s small enough for the payoff.