This post contains content about Now 1.0 – Learn about the latest version, Now 2.0.
Now 2.0 - Upgrade Available
Thursday, January 19th 2017 (over 2 years ago)

Faster JavaScript Deployments

Nathan Rajlich (@tootallnate)
Guillermo Rauch (@rauchg)
Tony Kovanen (@tonykovanen)

As we mentioned when we introduced Docker support, there are three pilars or universal targets that most software teams build upon and you can deploy by running now:

  • JavaScript: any directory with a package.json file.
  • Static files: HTML websites or even just a list of videos and images.
  • Linux containers: any project with a Dockerfile.

This announcement will focus on the first one. Starting today, you'll notice a major speedup when deploying and building JavaScript projects that rely on the vast npm ecosystem.

The Background

JavaScript is everywhere. Your backends could be written in Rocket (Rust) or Sinatra (Ruby), but your frontend code probably involves a build process that results in .js bundles the browser can interpret. Maybe you're writing a universal server-rendered application that runs both on Node and the Browser.

Now is designed to run JavaScript at scale, so it was conceived with these build processes in mind. For example, if you define a build entry inside scripts in your package.json, we execute it before we run start:

{
  "name": "your-project",
  "scripts": {
    "build": "webpack",
    "start": "node server.js"
  }
}

When we scale your servers up and down, the build step is cached and we run start directly

Over time, the complexity of JavaScript builds has increased significantly. It's not unusual for build processes or npm install to take several minutes. All of that for a language that was known for its agility, light weight and dynamism!

One of our goals is to make the cloud do more work on your behalf and make teams more productive. For you not to have to transfer gigabytes of packages or images around. For production deployments to be instant and secure.

Let's look at how we are doing!

The Data

Until now, our npm installations took advantage of a few tricks to speed up package downloads, heavily parallelize and reduce disk I/O as much as possible, similar to how yarnby Facebook works today.

We've now taken this model further, by introducing a global shared cache of public modules that avoids repetitive work in each deployment. For example, if you deploy node-canvas, which involves a lengthy C++ compilation, our build servers only do it once and securely share it with all our customers.

Here are some examples of our results:

dependencybeforeafter+ yarn.lock+ npm-shrinkwrap.jsondelta
react16 deps2.7s1.7s1.4s1.6s1.9xfaster
express42 deps2.0s0.9s1.0s0.9s2.2xfaster
socket.io45 deps2.0s0.9s0.9s0.9s2.2xfaster
ghost506 deps27.3s9.2s8.9s8.8s3.1xfaster
node-sass179 deps12.3s3.7s3.5s3.1s4.0xfaster
canvas2 deps19.6s0.9s0.6s0.7s32.7xfaster

With this update, almost every build step will see a noticeable improvement. If your projects contain a npm-shrinkwrap.json oryarn.lock file, you'll see a pretty dramatic change, in the order of 2 to 30 times faster.

What's more, in each case, the result is exactly the same as what npm or yarn would do locally, which means thereare no surprises when you take your apps to the cloud.

How is this accomplished?

We are introducing two new technologies behind the scenes:

1. Semver Aware Caching

One of the difficulties of caching npm modules is that the dependencies are not "set in stone", even if you use precise versions (frequently referred to as dependency pinning).

For example, let's say I want to request socket.io at version 1.7.2. Naively, you might assume that because the user wants a specific version, you can download it, install it and cache it as /tmp/socket.io-1.7.2.

Unfortunately, this is not always the case. Let's look at a fragment of its dependency tree. I'll designate static dependencies in green and dynamic ones in red.

  • socket.io 1.4.2
  • debug 2.3.3
  • engine.io 1.8.3
  • has-binary 0.1.7
  • object-assign 4.1.0
  • socket.io-adapter 0.5.0
  • socket.io-client 1.7.2
  • socket.io-parser 2.3.1

It all looks green! However, let's go one level deeper, into engine.io

  • engine.io 1.8.2
    • accepts 1.3.3
    • base64id 1.0.0
    • debug 2.3.3
    • engine.io-parser 2.0.0
    • ws 1.1.1
    • cookie 0.3.1

And now deeper into ws:

  • ws 1.1.1
    • options >=0.0.5
    • ultron 1.0.x

A-ha! If we had naively cached ahead of time, optionsand ultron would have forever been stuck at 0.0.5and 1.0.2 forever. If that package had received updates (like performance improvements or security hotfixes), a discrepancy would have been introduced between the cache and what a fresh npm install of this project would have done.

Our build system is aware of the red zones of every module in the npm ecosystem. When a build is requested, only the red zones are invalidated, which has a tremendous impact on performance. In other words, it caches as much as can be cached. For security reasons, private npm projects, direct tarballs and Git / GitHub URLs are not cached.

This system ensures great robustness and predictability. However, dynamic dependencies can still pose a problem for many teams. What if the author of ultron pushes a new version that you were unable to test locally, an instant before you execute now to deploy?

We have two independent solutions to that problem:

1. Every Now url is a brand new deployment, with its own unique url. This means that when you deploy, you're actually always staging your changes, creating an opportunity to rule out issues, run continuous integration tests, etc.

2. We now support lockfiles, which create a manifest of specific versions your project was built and tested with. When these files are detected, we automaticall install dependencies according to the versions they specify.

2. Side-Effects Caching

Sometimes, even when a given module's source files can be completely cached, it's not enough to achieve great performance. This is the case of modules with preinstall and postinstall hooks, which are ways for npm modules to define scripts to be executed when the user invokes npm install.

Interestingly, these are the modules that in many cases take up most of the installation time. To solve this problem, our system caches not just files, but also the side-effects of the execution of the postinstall scripts.

If you go back to the table above, you'll see that one of the most drastic examples of this is node-canvas. We are able to improve installation time of that module by 32.7 times by re-using the cache that results from invoking the install hook, which in turn runs node-gyp and triggers the compilation.

To avoid compatibility issues, this cache is keyed by the Node.js version your particular deployment is using.

Another interesting example is fs-events. This module performs a dynamic download of a precompiled binary that matches the architecture and operating system of the user. This effect is also captured by our system for the benefit of all our customers.

Conclusions

This marks a very important milestone for our platform. On one hand, we're improving your productivity. The build time for your deployments will increase significantly; in some cases up to 30 times faster for large apps.

On the other hand, we're improving the predictability and reproducibility of your deployments with our first-class lockfiles support. This translates into fewer headaches resulting from your dependencies changing without your oversight and even faster deployment performance.

Node.js grew by 100% in 2016 and has 3.5 million developers. According to W3Techs, JavaScript is used by 94.4% of all websites.

We're very excited about bringing the cloud much closer, much faster to this growing community. We're not nearly done yet!