This article presents a comparison of HTTP client apps in Node JS and Rust and looks at different aspects of those projects such as CPU and memory metrics, build time and distribution package size.
- This article has been written by a full stack software engineer so no extensive backend optimisation have been done.
- The Rust code proposed using mainly synchronous APIs. It's a newbie's Rust code, so practices may not be best or idiomatic.
- Libraries picked up randomly and performance may be improved by finding better alternatives.
- The application considered in the study is a typical low load cron job type of service, but it's very specific kind of program, other backend application types might give distinct results.
In the modern world programmers use PaaS platforms to simplify DevOps and gain solid support for essential production service features such as maintainability (watch dogs and auto-restarts) and scalability + load balancing (spawning new processes on demand). For such platforms it is common to deliver and deploy applications as containers such as linux containers of FreeBDS jails. This provides many benefits:
a) decent freedom to choose development technologies such as programming languages, frameworks, libraries and tools. Everything which can runs on linux would work. You could even run a linux app on FreeBSD.
b) additional isolation - every process is run in a container and connected to its own input/output, environment variables, filesystem, and so system tools dependencies, meanwhile still running on the same hosting OS kernel, meaning lightweight and easy to restart. Also this gives an ability to finely control networking in order to disallow certain apps connecting to certain others etc
At the same time while having all this neat higher level abstractions and conveniences we shouldn't forget that in the end every piece of code in production is going to be scheduled on a physical node and require processors' time, memory and IO to run. And all of this is going to add costs to run the service. This is especially important if a system is going to run at high scale. Where every small runtime performance difference will sum up to a big monthly loss or saving to a business.
While in a startup phase where business generates no profit yet, it's getting even more important to save computational resources as much as possible.
So it is useful to take several different software development technologies and implement a reference app in each of them in order to benchmark their runtime behaviour and resources consumption. This article does this for a sample app in Node.js and Rust.
The tool we are going to create should run a simple loop:
- connect to Kraken cryptocurrency exchange public API
- read buy/sell prices for some cryptocurrency tickers
- write those prices to a google spreadsheet
- sleep for some time
The configuration should be flexible and read from a JSON file which specifies which tickers to read from Kraken and where to write them in a Google spreadsheet. This may be modelled like this:
"sheet": "15CcPNtMZUSYhO99NOiFyHNjZCmgkvWnV5sKrGrtFLOk:Sheet 1",
Rust has more dependencies just because it's more lean by default, e.g. Node has a JSON parser as part of its standard library. In both apps we use bare minimum of dependencies to conveniently send web requests to kraken and leverage existing code for google API client rather than doing this manually with bare HTTP. Rust also has OAuth dependency explicitly where on Node it is hidden in googleapi's transitive dependencies.
Both apps produce a docker container which were run with Kubernetes plus Docker on a virtual server. Hardware used for testing Hetzner CX50 server.
|4 vCores||16 GB RAM|
Time to build Rust app from scratch is huge, it takes 11.5 minutes or 690 seconds to download and compile all dependencies on Air. Note: This might get better in the future if Rust community implements loading binary dependencies rather than compiling them every time.
When only app code is changed a docker caching may be used to allow rebuilding of just a project's source in about 27 seconds which is about 20 times faster than initial build.
If we compare to node js app which doesn't use any compilation here then we'll find the winner soon. Node finishes build in 18s when NPM install is required, once dependencies are cached docker finishes building an image in just 4s.
In comparison to Rust, Node takes 2.6% time or 38 times faster for initial build. And 7% time or 13 times faster for rebuilds.
Some Node.js apps or in case of a frontend which uses webpack to transpile JS code and create a bundle it would comparable to Rust build time on CI servers. But still webpack will probably give faster recompilation when running in dev mode locally.
The docker image with Rust binary weights 28.6MB and created from frolvlad/alpine-glibc.
The Node.js image weights 128MB and created from mhart/alpine-node:10.
Alpine Linux had been given a preference due to its compactness and targeting for container environments. Rust image we got here is about 4x times less, this results in faster CI uploads and new version deployments.
Runtime metrics were registered using the following methodology. Processes were run as docker containers on kubernetes cluster, cadvisor was used to fetch metrics from containers and Prometheus was used to scrape metrics and build charts.
Comparing to Rust (green line), Node.js has to do more things to boot (red spikes in the beginning). After that both processes consume similar amount of CPU. Which is roughly 0.75% CPU here.
Fast bootstrap for Rust means cheaper restarts. Cheaper restarts means better resilience as PaaS might shut down old ones and spin out new instances faster.
Overall after 10 minutes running Rust app has consumed more than 2 times less CPU than its Node.js counterpart. I'm assuming this related to the low level nature of Rust - so that in many cases it avoids looking for virtual functions and rather uses specific version of generic implementations when called. Where JS being dynamic must check a method exists at runtime.
Memory consumption on Rust is 5 to 10 times less which is circa 10-20% of Node.js consumption. Moreover memory profile shows that Node.js has some memory leak issue, which needs some investigation. Where Rust consumes just a small amount of constant memory volume. Which looks robust enough standalone without any extra maintenance. Kudos to Rust memory ownership model which avoids both - manual memory management and garbage collector. The garbage collector exists in many languages and it was invented to solve the problem of memory allocation/deallocation, which used to be very error prone when done manually. On the other hand it hides many of implementation details from a programmer and more importantly takes control of cleaning memory which may happen at a point of time when it's not desired.
On the other hand Rust frees memory as long as a variable which owns it goes out of the scope. This gives us such an amazing result. Just imagine you can run at least 7x more Rust containers on a single machine then you can do it for Node.js taking with the same amount of memory!
Two similar micro services were built using Rust and Node.js toolchains. Their runtime performance was compared on a single machine in normal conditions.
The source code for both applications is available on Github.
Rust is a promising system development language producing small and fast code to support any kind of modern applications. If we take scale of a big company where infrastructure may cost thousands dollars daily saving 50% of resources by running the apps written in Rust may be a good competitive advantage.