Joel Griffith @browserless @elastic It only has to work

You don’t need Babel with Node

7 min read 1964

Why You Don't Need Babel With Node.js

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!

De-Babeling step #1: Dealing with modules

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 imports or requires are resolved since many libraries bundle for different module systems.

You can read more about this on Node’s excellent documentation site, located here.

De-Babeling step #2: Using modern async control flow

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)

De-Babeling step #3: Keep the sugar!

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.

But wait, there’s more!

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.

Finally, why you still might need Babel

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!

Plug: , a DVR for web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Joel Griffith @browserless @elastic It only has to work

Leave a Reply