Choosing a JavaScript Runtime for 2023: Node vs. Bun vs. Deno
Atwood’s law states that any application that can be written in JavaScript will eventually be written in Javascript. This law was written two years prior to the inception of Node.js and turned out to be spot on. The arrival of environments for running JavaScript outside of the browser context encouraged people to write servers, CLIs, and even machine learning algorithm models in pure JS.
Although Node.js is the de facto standard for running JavaScript on the server, not everyone is ecstatic with it, especially not its author!
10 Things I Regret About Node.js - Ryan Dahl - JSConf EU
Node
The original JS backend runtime, Node, was written in 2009 by Ryan Dahl. It is much older and by far more popular than the alternatives. In fact, it is so popular that there is no need to talk too much about it. Instead, let’s go straight to a summary:
Advantages:
- Extremely popular → many community projects, resources, job offers, etc.
- Overall, it is pretty nice and easy to use
Disadvantages:
- Runs only JavaScript – TypeScript projects require additional tooling such as ts-node or swc-node
- Vendored-by-default
node_modules
take up a lot of space and don’t scale with the numbers of projects - API not compatible with browser API.
Deno
Deno is a JavaScript and TypeScript runtime that was also created by Ryan Dahl, but almost 10 years later. Deno was designed to address some of the shortcomings of Node.js, such as its security model, build system, and lack of built-in tooling.
Some of its key features include first-class TypeScript support, a built-in linter, formatter, and test runner, and the ability to bundle scripts into a single JavaScript file or executable. Deno is gaining popularity as a more secure and simpler alternative to Node.js.
Advantages:
- First-class support for TypeScript
- Built-in linter, formatter, and test runner
- Built with security in mind
- Supports many Node and npm Modules
- Adopts web platform standards for better DX. For example
fetch
,WebSocket
, or evenlocalStorage
.
Disadvantages:
- Much less popular than Node
- Still a little immature; some APIs and methods are unstable or not ready
- Currently, migrating a Node project to Deno may be very difficult.
Let me take you on a short tour of some features I have found especially interesting.
Installation
To get started let’s install Deno by running:
curl -fsSL https://deno.land/x/install/install.sh | sh
For alternative ways to install Deno, check out their installation page.
Deno is just a single binary executable with no external dependencies. After running the script above and adding Deno to the PATH variable, we are good to go.
The next step is to set up the development environment in your IDE. In my case, I simply added an official Deno extension to VS Code and enabled it in my workspace.
Deno has LSP integrations for a lot of editors/IDEs. It also offers CLI autocompletion’s for popular shells. You can check out the full list of integrations here.
First program
We are ready to write our first Deno program! Here is an example taken from the official docs:
// main.ts
const url = Deno.args[0];
const res = await fetch(url);
const body = new Uint8Array(await res.arrayBuffer());
await Deno.stdout.write(body);
It gets a URL from the command-line arguments, fetches the given website, converts it to Uint8Array
, and prints it to the standard output.
Deno tries to use modern features and web standards wherever possible, so it shouldn’t surprise us that we can use top-level await
and that fetch
is available without any imports. Another thing to notice is the Deno
global variable that provides us with many server-side, or Deno-specific APIs.
Security
Let’s try running this code with deno run main.ts
:
Here’s another cool Deno feature. No file, network, or even environment access is given to scripts unless explicitly enabled. The idea is that your linter doesn’t need to send anything over the network, nor write permissions to your entire computer. In Deno, scripts are sandboxed by default, and if they need some access, you will be prompted for it.
Of course, we don’t have to be prompted every time. We can use deno run
with different flags to give granular permissions to scripts. For example, running deno run --allow-net=[softwaremill.com](<http://softwaremill.com>) main.ts [<https://softwaremill.com>](<https://softwaremill.com>)
will give network access to the main.ts
script but only for the softwaremill.com
domain.
TypeScript
Did you notice that we just ran a TypeScript file like it’s nothing? One of Deno’s goals was to provide a first-class experience for developing TypeScript projects. So instead of setting up our transpilation step by hand, as we would do in a Node.js project, Deno takes care of all of it. And it does it in a really smart way by keeping a cache so that only what is necessary gets checked and transpiled during consecutive runs.
I really like the decision of keeping the type checking and execution apart by default. When we run a script with deno run script.ts
, it gets transpiled with swc
and run without any type checking. We can type check some module by running deno check script.ts
. And finally, we can combine the two by running deno run --check script.ts
.
It makes perfect sense to keep the two apart since:
- Type checking may be long
- While developing you often want to prototype and run code that hasn’t been well annotated yet.
Partner with Typesctript, React and Next.js experts to make an impact with beautiful and functional products built with rock-solid development practices. Explore the offer >>
Configuration file, tasks, and watch mode
Deno allows you to place a configuration file, called deno.json
, in the root of our project. With it, you can customize the built-in TypeScript compiler, formatter, and linter. Here’s an example:
{
"compilerOptions": {
"lib": ["deno.window"],
"strict": true
},
"imports": {
"std/": "https://deno.land/std@0.174.0/"
},
"tasks": {
"dev": "deno run --watch src/hello.ts"
},
"lint": {
"files": {
"include": ["src/"],
"exclude": ["src/testdata/"]
},
"rules": {
"tags": ["recommended"],
"include": ["ban-untagged-todo"],
"exclude": ["no-unused-vars"]
}
},
"fmt": {
"files": {
"include": ["src/"],
"exclude": ["src/testdata/"]
},
"options": {
"useTabs": true,
"lineWidth": 80,
"indentWidth": 4,
"singleQuote": true,
"proseWrap": "preserve"
}
}
}
compilerOptions
field can be used to configure the TypeScript in the project. Deno supports most of the TS compiler options.
imports
field lets us add custom import mappings. For example, with the configuration above, we can write:
import { assertEquals } from "std/testing/assert.ts";
assertEquals(1, 2);
Instead of:
import { assertEquals } from "https://deno.land/std@0.174.0/testing/assert.ts";
assertEquals(1, 2);
lint
and fmt
are for configuring the built-in linter and formatter and tasks
are like scripts in package.json. You run them with deno task <task_name>
.
I like how we can keep all configs in a single file. From what I’ve seen, the default values are reasonable, which favors keeping the file small.
Another cool feature that Deno has is a built-in --watch
flag for deno run
, deno test
, deno bundle
, and deno fmt
. No need for nodemon for Deno users.
Modules
Modules in Deno are handled differently than in Node.
- File names must be specified in full – you can’t omit the file extension
- There is no special handling of index.js
- Third-party dependencies are imported by specifying a URL in an import statement.
// local, filesystem import
import { add } from "./arithmetic.ts"
// remote import
import { multiply } from "https://x.nest.land/ramda@0.27.0/source/index.js";
This, I think, is one of the largest mind-shifts one has to make when starting to use Deno. If it seems weird to have all these links in the import statements, you are not alone. Here is what the Deno team has to say about that:
The solution is to import and re-export your external libraries in a central
deps.ts
file (which serves the same purpose as Node'spackage.json
file). For example, let's say you were using the above assertion library across a large project. Rather than importing"https://deno.land/std@0.177.0/testing/asserts.ts"
everywhere, you could create adeps.ts
file that exports the third-party code.
Testing
As you may have guessed at this point, Deno has its own built-in test runner. It is pretty bare bones, compared to a tool like Jest as it mostly provides an API for defining tests and test steps. When it comes to making assertions, test setups, and tear-downs you are supposed to do it in vanilla JS instead of the it.is.not.equal
chaining approach that other frameworks accustomed us to.
In a way, it reminds me of how testing works in Rust. However, in contrary to Rust, you can’t put your test cases in the same file as the implementation. From what I noticed, Deno looks for tests in *.test.{ts,js,tsx,jsx}
and a few other globs, other instances of Deno.test
are ignored.
I’m kind of disappointed by that because I like the DX of putting unit tests alongside the implementation. I really enjoy it in Node-based projects with Vitest and of course in Rust. Since a lot of Deno’s tooling seemed to be inspired by Rust’s ecosystem, I was anticipating this feature as well.
Interoperating with Node.js and npm
Since version 1.28, it is possible to use npm packages inside Deno projects by using npm:
specifiers like so:
import lodash from "npm:lodash@^4.17";
console.log(lodash.map([1, 2, 3], (n) => n * 2));
If the package is written in TypeScript or provides its own types, everything works as it should. However, if the package doesn’t provide types, we might experience some issues:
import lodash from "npm:lodash@^4.17";
console.log(lodash.map([1, 2, 3], (n) => n * 2));
// ^ error here:
// Parameter 'n' implicitly has an 'any' type.deno-ts(7006
Luckily, we can fix it with a @deno-types
directive similar to how we can install additional types as dev dependencies in Node:
// @deno-types="npm:@types/lodash"
import lodash from "npm:lodash@^4.17";
console.log(lodash.map([1, 2, 3], (n) => n * 2));
// ^ n is inferred as a number, Nice!
Although Deno looks amazing, it's important to keep in mind that support for npm packages is still a work in progress. Some packages may not work with Deno, and tools like deno bundle
and deno compile
don't work in projects using npm:
specifiers either.
Nevertheless, I appreciate that the compatibility layer between Node and Deno is being worked on, as it is a crucial factor for many developers who want to make the switch.
Bun
Bun is the new kid on the block. While it's still in the oven, it has already gotten a lot of people excited. Speaking of ovens, Oven is also the name of the company founded by Bun's creator, Jarred Sumner, which raised $7 million in funding to "lead Bun's development, offer hosting, and grow Bun into an end-to-end solution for JavaScript," as we can read on their website.
Bun has three main goals:
- Execute JavaScript as fast as possible
- Provide great, complete, and fast tooling (bundler, package manager, etc.)
- Be a drop-in replacement for all existing Node projects.
As we can see, it takes a much different approach than Deno. Instead of reimagining how different the ultimate server-side JavaScript experience could be, Jarred Sumner is thinking about how fast (but also delightful) this experience can be.
Performance
While Deno uses V8, the same JavaScript execution engine as Node, Jarred noticed that WebKit's (Safari) engine, JavaScriptCore, tends to be a little faster and more memory-efficient. Embedding JSC into your runtime is trickier to do, though, which is why V8 is the traditional choice. However, Jarred is not afraid of work, and his top priority is clear: to take JavaScript to the next level of performance.
But that's just the execution engine. What makes all other parts of the runtime so fast? Benchmarking and optimizing everything that can be optimized. Bun is written in Zig, which is a low-level language with manual memory management that allows for writing extremely performant code but also... for bugs causing the program to segfault. And there are currently a lot of bugs.
Luckily, with each release, a lot of bugs are being squashed and Bun becomes even faster!
Tooling and developer experience
Even though Bun is still in its early stages, it already presents an impressive suite of tools.
For starters, Bun can be used as an npm-compatible package manager. I tested it on a rather large repository that requires over 3,000 packages to be installed, and here are the results:
- yarn (no yarn.lock) → 236 s
- yarn (with yarn.lock) → 63 s
- bun install (no bun.lockb) → 21 s
- bun install (with bun.lockb) → 6.77 s
Wow, that's a 10x improvement!
Bun can also be used as a task runner. By simply running bun run start
instead of npm run start
, you can save over 150 ms of overhead that is added by npm.
Another tool is Bun's test runner, which is on its way to being fully Jest-compatible.
To speed up your development, you can use the aforementioned tools, as well as Bun's own bundler and transpiler independently of using Bun as a runtime!
Summary
In conclusion, Node.js is currently the most popular choice for running JavaScript on the server, and it has a huge community and many resources available.
However, Deno is a promising new alternative that offers improved security and improved development experience, especially for TypeScript projects.
Bun, while still in early development, aims to provide the fastest possible JavaScript execution and a complete set of tools, making it a compelling option for those seeking performance.