New Node.js 15 features could make your app leaner and meaner

Rudy Weber
Rudy Weber
Developer Relations Engineer
04 Nov 2020

As the Node.js 14.x release line enters the list of versions in Long Term Support under the name ‘Fermium’, Node 15 brings fresh features and changes: new npm version, V8 upgrade, AbortController, and more.

It’s hard to keep up with the latest JavaScript features without ending up relying on polyfills or transpilers. Adding extra steps to your pipeline and making your codebase larger is the price to pay for cutting-edge JavaScript applications. While we do our best at Platform.sh to minimize the impact this bloat has on your build and deployment times, we can’t remove steps that are needed for your application to run.

The good news is the Node.js 15 release might help your application lose some extra kilobytes of now unnecessary libraries and polyfills, making it lighter and your pipeline faster. And that’s not all! Let’s see what’s new.

But first …

Will it break?

A new major version brings the risk of breaking changes, so upgrading your runtime is not a decision to be taken lightly. While switching to Node 15 should be safe for most applications, it is worth knowing that your application might break under this new version. The reason for this is that the default unhandledRejection mode has changed from warn to throw. This means that if you ever encountered an unhandledRejection when running your application, it will now crash instead of warning you.

If this decision can seem a bit brutal, it actually makes a lot of sense. Unhandled rejections are a sign that your application lacks error management, which can lead to an unstable state and unpredictable behavior. Node 15 is now less flexible regarding those situations and encourages developers to handle them. In order to allow for an easier migration process, it is possible to use the old behavior by setting the --unhandled-rejections option to warn.

If you are using npm, be aware that Node 15 comes with npm 7, which also comes with a few — potential — breaking changes:

  • Peer dependencies are installed by default. This is desirable in most situations but might break some workflows.
  • npm’s internal modules can no longer be imported.
  • npx will start an interactive prompt if trying to execute a non-installed package.
  • The output format of npm audit has changed.

Also, before we dive into Node 15’s new features, keep in mind that they are experimental.

New V8 8.6 features

Upgrading the V8 engine from 8.4 to 8.6 brings some performance improvements but also allows Node 15 to catch up with some of the latest browser-supported features: logical assignment operators, String.prototype.replaceAll, Promise.any, and AggregateError.

Logical assignment operators

JavaScript has a long list of compound assignment operators, to which we can now add the logical operators (&&, ||, ??). This now gives us &&=, ||=, and ??=.

Here are some usage examples of these operators:

const userInformation = {
  name: "user a",
  nationality: "",
  favouriteFruit: null
};

userInformation.name &&= name.replace(/(^[a-z]|\s[a-z])/g, c => c.toUpperCase()); // User A
userInformation.nationality ||= "Unspecified"; // "Unspecified"
userInformation.favouriteFruit ??= "Unspecified"; // "Unspecified"

Note that these operators are evaluated left to right, so they can be used to short-circuit expressions:

  • x &&= y(). y() will not be executed if x is truthy.
  • x ||= y(). y() will not be executed if x is falsy.
  • x ??= y(). y() will not be executed if x is null or undefined.

String.prototype.replaceAll

Replacing all occurrences of a string by another is a very common operation. But doing so has not been as straightforward as one might think. One solution is to use a RegExp that represents the string to substitute, setting the global flag to make sure all occurrences will be replaced. This works but is error prone, as special characters need to be escaped when converted from String to RegExp:

"1 + 1 + 1 = 1".replace(/\+/g, "*");

Another solution is to use split with the string to be replaced and then join with the replacement:

"1 + 1 + 1 = 1".split("+").join("*");

Although both valid, those solutions are not ideal, as they bring extra steps, potential errors, and performance cost. Luckily, it is now possible to do:

"1 + 1 + 1 = 1".replaceAll("+", "*");

This is way more natural and straightforward.

Promise.any

Promise.all makes it possible to wait for all promises to be resolved, Promise.race allows a wait for the first promise that settles, and Promise.allSettled resolves once all promises have either fulfilled or rejected. But what if all we want is to run several promises at once, and all we care about is the first one that fulfills, ignoring the ones that might have been rejected and the ones that are yet to settle? This is now possible using Promise.any:

const resolveLater = (value, delay) => new Promise(resolve => setTimeout(() => resolve(value), delay));

Promise.any([
    Promise.reject("First!"),
    resolveLater("Second!", 5000),
    resolveLater("Third!", 1000)
  ]).then(result => console.log(result)); // displays "Third!"

Note: if you run this example, you will notice that even though Promise.any has resolved, the program waits before exiting. This is because even if discarded, our other promises are still running. (Later on in this article, we’ll see how to handle this with another new Node.js 15 feature.)

It’s possible to use catch, but the function will only be called if all promises are rejected, and it will do so with a new error type: AggregateError.

AggregateError

As seen with Promise.any, we need a way to wrap multiple errors in one. To do so, the AggregateError object has the following properties:

  • AggregateError.prototype.name: the error name that defaults to AggregateError
  • AggregateError.prototype.message: the error message that defaults to an empty string
  • AggregateError.prototype.errors: an array of the returned errors

Let’s see an example:

Promise.any([
  Promise.reject("I failed."),
  Promise.reject(new Error("so did I.")),
]).catch((aggregatedError) => {
  console.log(aggregatedError.name);
  console.log(aggregatedError.message);
  console.log(aggregatedError.errors);
});

This code will output:

AggregateError
All Promises rejected
["I failed.", Error: "So did I."]

Node.js specific features

AbortController

Apart from V8, Node.js itself has some new interesting features, one of the most exciting ones being AbortController. If you ever had to cancel promises in the browser, you’re probably already familiar with this API, as it is directly based on its web counterpart. We can now take our Promise.any example from a few sections above and solve the problem we had:

const cancellableResolveLater = (value, delay, signal = {}) => {
  if (signal.aborted) return Promise.reject(new Error("Aborted"));

  return new Promise((resolve, reject) => {
    let timerId = null;

    const onAbort = () => {
      clearTimeout(timerId);
      reject(new Error("Aborted"));
    };

    timerId = setTimeout(() => {
      signal.removeEventListener?.("abort", onAbort);
      return resolve(value);
    }, delay);

    signal.addEventListener?.("abort", onAbort, { once: true });
  });
};

const controller = new AbortController();
const { signal } = controller;

Promise.any([
    Promise.reject("First!"),
    cancellableResolveLater("Second!", 50000, signal),
    cancellableResolveLater("Third!", 1000, signal)
  ]).then(result => {
    controller.abort();
    console.log(result);
  });

process.on("unhandledRejection", (error) => {
  // Let's ignore cancelled promises
  if (error instanceof Error && error.message === "Aborted")
    return;
});

We’ll no longer have to wait for every promise to be settled before the process can end, as they will all be cancelled when the first promise resolves! This might seem like a lot, but functions like cancellableResolveLater are most likely to live in a library rather than directly in your application code.

timers/promises

You’ll have to dig into the documentation to find this one: setTimeout and setImmediate can now return Promise objects! The absence of a delay function has confused more than one JavaScript newcomer, and writing your own is not as easy as it sounds when you’re just getting started. Waiting for one second is now as direct as writing await setTimeout(1000);.

It’s also possible to give the value to resolve with:

import { setTimeout } from "timers/promises";

const result = await setTimeout(42, 1000);

console.log(result); // 42

NPM 7

We talked about what npm 7 might break in your setup but not about its new features: workspaces and new package-lock format!

Workspaces

Workspaces are a way to organize your project that is especially suitable when developing multiple packages that depend on one another. This feature — which has been supported by Yarn since version 1.0, but also by pnpm and lerna — is now arriving in npm! You can learn more in the workspaces implementation description in npm’s rfcs repository.

New package-lock.json format

When dealing with JavaScript code destined to go into production, having deterministically reproducible builds feels essential. The code you test has to be the exact same when delivered. It also makes it easier to track down bugs when you can build an application and be assured that the code is strictly identical to the one in which the bug occured. Sadly, this is no easy feat. Dependency resolution is a hard task, and the more dependencies you have, the harder it gets.

This is the problem npm is trying to solve with their new package-lock.json format. This reviewed format will allow npm to make builds deterministic. In their effort to bring this feature, npm also supports yarn.lock and leverages this file to get packages metadata when available.

Using Node.js 15 on Platform.sh

Since this version is not an LTS, Platform.sh doesn’t provide a pre-made image. However, any Node.js version can be used on Platform.sh thanks to nvm. Whether you’re using one of our Node.js templates or your own existing project, you can modify your .platform.app.yaml file like so:

variables:
    env:
        NVM_VERSION: v0.36.0
        NODE_VERSION: v15

hooks:
    build: |
        unset NPM_CONFIG_PREFIX
        export NVM_DIR="$PLATFORM_APP_DIR/.nvm"
        # install.sh will automatically install NodeJS based on the presence of $NODE_VERSION
        curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/$NVM_VERSION/install.sh | bash
        [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
        # rest of your build hook

Finally, you will have to add an .environment file containing the following lines:

# This is necessary for nvm to work.
unset NPM_CONFIG_PREFIX
# Disable npm update notifier; being a read only system it will probably annoy you.
export NO_UPDATE_NOTIFIER=1
# This loads nvm for general usage.
export NVM_DIR="$PLATFORM_APP_DIR/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

Your project is now configured to run Node.js 15 with all these new features ready to play with.

Whether you’re cutting down on some dependencies, experimenting with npm workspaces, or just giving this version a try, we can’t wait to see what cool things you’re going to build on Platform.sh! Let us know how Node.js 15 impacted your project, and be sure to share your experiments with us @platformsh or directly with me @rudy_weber.

For more information about Node.js on Platform.sh, you can read our Node.js documentation page.