If you’ve been active as a Node.js developer, or even dabbled in front-end libraries like React or Vue.js, then there’s no doubt that you’ve likely run across Babel. What once began as a humble side project on Reddit, which you can see here, has now grown so much that it has fundamentally transformed how we build and develop Node.js applications.
It’s hard to contextualize just how big Babel is since it’s now broken out into separate small packages, but just taking a look at the npm @Babel/core
package should give you an idea of its nature (hint: it has roughly 8 million downloads a week, whereas React has 5 million!).
As amazing as Babel is, it does bring with it a few things that are hard to stomach. The first is that you’ll now need to introduce a build system into your application or library. While not a terrible thing in and of itself, it does come with a lot of other complexities and questions: Do you package both an ES-compatible library and an ES20XX version? What “stage” of the ECMAScript specification do you want to target? And, my personal favorite, how are your current set of tools going to work with it (debugging and otherwise)?!
Of course, we can’t forget our old source maps friend so we can intelligently work backwards from the transpiled code to what our source looks like. The water gets even murkier if you’re building for both browsers and Node.js since you’ll have to bundle a build for browsers as well — sheesh!
What I’d like to argue, instead, is that maybe you don’t need Babel anymore. Most of the cool bells and whistles that were once Babel-only are now in Node.js as first-class citizens, meaning you can erase a number of dependencies, build steps, and even third-party systems that do this compilation for you automatically.
Once you’ve read the entirety of this post, I hope that you’ll see with me that we’ll hopefully enter a “renaissance” of Node development where you won’t need a build system any longer — including Babel!
One of the more confrontational parts of JavaScript development has always been its module system. For those unfamiliar, you’ll probably see this syntax a lot on the web:
export const double = (number) => number * 2; export const square = (number) => number * number;
However, running the above code in Node without any sort of Babel-ifying (or flags) will net you the following error:
export const double = (number) => number * 2; ^^^^^^ SyntaxError: Unexpected token export
Folks from years back can probably recall a time when we entertained both requirejs
and commonjs
syntax, and how strikingly similar it is today now that we’re juggling both commonjs
and ECMAScript module syntax.
However, if you’re running Node — even as old as version 8 — you can start using ECMAScript modules without any transpiling or Babel. All you need to do is start up your app with the --experimental-modules
switch:
node --experimental-modules my-app.mjs
Of course, the big caveat — at least in versions 8 and 10 — is that your files have to end with the extension mjs
to disambiguate that they’re ECMAScript modules and not CommonJS. The story gets much better in Node 12, where all you have to do is append a new property to your application (or libraries) pacakge.json
:
// package.json { "name": "my-application", "type": "module" // Required for ECMASCript modules }
When using the type
method on Node.js 12 and above, it has a nice side effect of loading all your dependencies with support for ECMAScript modules as well. Thus, as more and more libraries move over to “native” JavaScript, you won’t have to worry about how import
s or require
s are resolved since many libraries bundle for different module systems.
You can read more about this on Node’s excellent documentation site, located here.
If you’ve been enjoying the more modern methods of async control flow in Node.js (namely promises and their counterparts async/await), then you’ll be happy to know that they’ve been natively supported since Node 8!
Having good control flow, especially for operations like issuing requests in parallel, is crucial to writing fast and maintainable Node applications. To use things like Promise
or await
in Node 8, there’s nothing special you even need to do:
// log.js async function delayedLogger(...messages) { return new Promise((resolve) => { setImmediate(() => { console.debug(...messages); resolve(true); }); }); } async function doLogs() { delayedLogger('2. Then I run next!'); console.log('1. I run first!'); await delayedLogger('3. Now I run third because I "await"'); console.log('4. And I run last!'); } doLogs();
Running this example now becomes trivial:
node log.js
No special switches or updates to your package.json
— it just works! Not only that, you can even use these native promises to try and catch uncaught exceptions in case things go haywire in your application:
process.on('unhandledRejection', (reason, promise) => { console.log('Unhandled Rejection at:', promise, '\nMessage:', reason); }); async function willThrowErrors() { return new Promise(function shouldBeCaught(resolve, reject) { reject('I should be caught and handled with!'); }); } willThrowErrors();
As nice as this is, it can sometimes be especially challenging if we need to look deeply into the async call stack and see what’s throwing and how we got there. In order to enable async stack traces, you’ll need to be on Node 12 and use the --async-stack-traces
switch for certain versions.
Once done, you can then better reason about where errors are coming from and work your way back to the source of your issues. For example, the following contrived program can be hard to see how we ended up in an error:
// app.js async function sleep(num) { return new Promise((resolve) => { setTimeout(resolve, num); }); } async function execute() { await sleep(10); await stepOne(); } async function stepOne() { await sleep(10); await stepTwo(); } async function stepTwo() { await sleep(10); await stepThree(); } async function stepThree() { await sleep(10); throw new Error('Oops'); } execute() .then(() => console.log('success')) .catch((error) => console.error(error.stack));
Running this in Node 10 returns the following trace:
$ node temp.js --async-stack-traces Error: Oops at stepThree (/Users/joelgriffith/Desktop/app.js:24:11)
Once we switch over to Node 12, we now get a much nicer output, where we can see exactly the structure of our call:
$ node temp.js --async-stack-traces Error: Oops at stepThree (/Users/joelgriffith/Desktop/temp.js:24:11) at async stepTwo (/Users/joelgriffith/Desktop/temp.js:19:5) at async stepOne (/Users/joelgriffith/Desktop/temp.js:14:5) at async execute (/Users/joelgriffith/Desktop/temp.js:9:5)
One of the really nice benefits of Babel was all the great syntactic sugar it exposed from ES6 a few years back. These little benefits made it easier to perform frequently used operations in a way that is a lot more readable and less terse. I’m more than happy to say that since version 6 of Node, most of these things work swimmingly.
One of my personal favorites is destructuring assignments. This little shortcut makes the following a lot easier to understand and doesn’t require any build system to play nicely with Node:
const letters = ['a', 'b', 'c']; const [a, b, c] = letters; console.log(a, b, c);
If you only care about the third element, then the following also works, even though it looks a little jarring.
const stuff = ['boring', 'boring', 'interesting']; const [,, interesting] = stuff; console.log(interesting);
Speaking of sugary syntax, object destructuring works out of the box as well:
const person = { name: 'Joel', occupation: 'Engineer', }; const personWithHobbies = { ...person, hobbies: ['music', 'hacking'], }; console.log(personWithHobbies);
Now, I will say that using object destructuring does require Node 8 in order to work, whereas array destructuring is supported as far back as Node 6.
Finally, default params (a sorely missing feature of the language) are now fully supported from Node 6 and up. This removes a lot of typeof
checks in your programs (as well as from Babel’s transpiled output), so you can do the following:
function messageLogger(message, level = 'debug >') { console.log(level, message); } messageLogger('Cool it works!'); messageLogger('And this also works', 'error >');
There’s just so much more that works in Node that I can’t even begin to scratch the surface: template literals, backticks (multi-line strings), fat arrows, and even the class
keyword are all there ready to go.
Getting rid of an unneeded dependency can be a great way to improve your application’s security and maintainability. You become less reliant on externally maintained software and are free to move a bit quicker without waiting for the ecosystem to catch up. However, in this case, by removing Babel, you are actually deploying much more readable code as well.
For instance, there are times when Babel injects numerous polyfills into the beginnings of your program’s files. While these helpers are totally harmless in most cases, it can cause a layer of indirection for newcomers or those unfamiliar with why that code is there to begin with. This is true in general: if a newcomer would be confused by it, then maybe it doesn’t belong in your project.
It also makes it much tougher for others who are consuming your package to determine if issues are coming from your code or your transpiler-injected helpers. You’ll also have a much better understanding of the fundamentals of what you’re building when there’s less code being injected into your final output.
The final point I want to make about ditching Babel is the same as taking on or removing any dependency, and that’s liability. Anytime you bring in code you haven’t personally read or know about, there’s an opportunity for something bad to happen. Slower npm install
times because of the huge dependency graph, slower boot times because of modules being monkey-patched on the fly, and false positives on issues can make adopting a package like Babel a no-go.
Taking on a new module or build process is something everyone has to figure out in their teams and projects, so I challenge you to start thinking more about it as a liability (maintaining it, upgrading it, and being aware of what’s going with it) than just a tool to leverage.
For all the progress Node has made recently, there are still times when you might just need Babel. If you want to run the “latest and greatest” the specification has to offer, then Babel is your only way. If you want to try TypeScript without having to change your entire build pipeline, then Babel can do that as well.
There are also times where Babel’s code is actually faster than Node-native methods. More often than not, it’s due to edge cases that the Node maintainers have to handle but that Babel doesn’t necessarily have to worry about. Given a few years’ time, I’m sure Node will eventually come out as the fastest overall, but newer features tend to be quite slower than those implemented in user-land.
Finally, if you’re delivering code to the web browser, then you’ll probably have to stick with Babel for the foreseeable future. Libraries like React and others that implement or enhance the language will always need a way to transpile down to browser-understandable code.
However, if you know your user base uses more modern browsers, then there’s even more benefit to ditching a build system since you also shrink your payload size down as well. This brings not only numerous benefits, namely quicker page load times, but also perceived performance gains, as even 1KB can cost a lot of time since every byte needs to be parsed and verified before being executed!
I hope this helps you on your journey to writing better, faster, and more secure Node.js applications — and especially all the features that are there without Babel!
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
4 Replies to "You don’t need Babel with Node"
Module support is no longer experimental!
I’d love to completely get rid of Babel, but some nice features like optional chaining are still unimplemented in Node.
+1
“If you want to try TypeScript without having to change your entire build pipeline, then Babel can do that as well.”
Can you please elaborate on this? I don’t see how Babel can be helpful for TypeScript.
Optional chaining is supported(now) from Node 14.