A look at native TypeScript performance
Sebastian Staffa
Published on Feb 24, 2025, 3:32 PM

The beginning of this year brought with it the release of
Node.js v23.6.0. An unassuming
minor release at first glance it includes a noteworthy change: The removal of
the experimental flag from the native TypeScript support, which previously
needed to be enabled with the --experimental-strip-types
flag. With Node.js
23.6 you can now run some TypeScript code without additional tooling. You
still cannot run code that requires transformation of the resulting code beyond
stripping the types. This, for example, includes enums and default parameters
for functions.
Nevertheless, I wanted to use this opportunity to see where the capabilities of Node's new native ts support are heading, especially when compared against the existing tools as well as the "new" challengers that have risen to fame in the last few years.
The contenders are:
- Node.js v23.6.0
- ts-node v10.9.2, both with and without SWC
- tsx v4.19.2
- Deno v13.0.245.12-rusty
- Bun v1.1.43
- Node.js v23.6.0 again, but this time we'll compile the TypeScript code to Javascript first
It is worth pointing out that none of the tools that I am testing actually perform any type checks on the code that is run. At least when using the default settings, all the runtimes require you to run the Typecript compiler separately to check your code for type errors.
There are, however, two notable exceptions: Both Deno and ts-node allow you to
enable type checking when running the code: for Deno you can use the
--check
flag, while ts-node has a
--typeCheck
flag. The latter is activated by default, but I was not able to get it to work.
For all comparisons in this article, type checking is turned off, unless
otherwise noted, to allow for an equal playing field.
Hello World
The first round of tests will use a simple Hello World
script, which will be
used to get a feeling for the startup time of the different tools:
function main() {
console.log("Hello, World!")
}
main()
All measurements are averaged over 100 runs and are taken on my Framework16 with an AMD Ryzen 9 7940H @ 5.2GHz:
Mean | Median | Std | |
---|---|---|---|
Node.js 23.06 | 48.39 ms | 48.00 ms | 1.326 ms |
Node.js 23.06 (js) | 24.21 ms | 24.00 ms | 1.032 ms |
ts-node | 463.28 ms | 457.50 ms | 16.168 ms |
ts-node + swc | 470.50 ms | 470.00 ms | 3.915 ms |
tsx | 319.80 ms | 319.50 ms | 3.240 ms |
Deno | 22.21 ms | 22.00 ms | 1.003 ms |
Bun | 10.56 ms | 10.00 ms | 0.863 ms |
When looking at these results, the performance difference between the two
Node.js execution modes is quite striking. The process of stripping the types
from the TypeScript files almost doubles the time it takes to print out
Hello World
. The two tools that are written in javascript themselves, ts-node
and tsx, are slower by orders of magnitude. The two rust tools, Deno and Bun,
are leading the pack with Deno being twice as fast as the Node.js TypeScript
implementation, while Bun cuts this time in half again, making it the clear
winner in this test.
Extensive Scripts
The previous hello world example is of course not representative of a real-world application. To get a better idea of the real world performance of the different tools I wanted to craft a more extensive example. My initial idea was to just check out one of the bigger, open source TypeScript repositories like Zod or Drizzle, but I quickly realized that just running one of the projects natively was not that easy. The problem lies once again with JavaScript's module system.
Node.js only supports CommonJS and ES Modules and it requires all imports to
contain the .ts
file extension. That is of course not a format that any of the
big projects use (yet). I could have gone ahead and written a script to fixup
all the imports, but instead went with a different route. As I wanted to test
the raw interpreter performance of the runtimes anyway, I thought it would be a
good idea to create a huge, single TypeScript "bundle" to make sure that all the
tools had to interpret all of a projects code and would not be able to skip over
unused imports.
This led to the next problem: I couldn't really find a bundler that would let me output TypeScript. Every tool assumed that I wanted to transform the code to JavaScript while bundling. Annoyed, I just wrote a small "bundler" myself which took all of a projects source files and concatenated them into one big file.
The capabilities of my frankly quite basic bundler along with the goal to test the performance of the interpreter resulted in two requirements for the project that I wanted to bundle:
Firstly, it should have a simple project structure as to not break my trivial bundler. Secondly, it should not call any non-TypeScript code. This is why I chose to bundle the Mathigon suite of tools. Mathigon is a math toolset that is used in a pedagogical context and is written purely in TypeScript.
From this toolset I chose the core, fermat, euclid and hilbert libraries to create my bundle. The result is a file with about 6k lines of pure ts code (including whitespaces).
But even though I made sure to only use the most pure and simple TypeScript code for this test, Node.js was not able to run the code without additional flags. The Mathigon libraries make heavy use of constructor parameter properties, which are not supported in Node.js' current default strip-only mode:
x TypeScript parameter property is not supported in strip-only mode
,-[5518:1]
5515 |
5516 | export class ExprTerm extends ExprElement {
5517 |
5518 | constructor(readonly items: ExprElement[]) {
: ^^^^^^^^^^^^^^^^^^^^
5519 | super();
5520 | }
Because of this I had to turn on --experimental-transform-types
to get the
code to run natively under Node. The other tools executed the bundle without
problems, leading to the following results:
Mean | Median | Std | |
---|---|---|---|
Node.js 23.06 | 168.97 ms | 169.00 ms | 3.315 ms |
Node.js 23.06 (js) | 28.89 ms | 29.00 ms | 1.303 ms |
ts-node | 609.32 ms | 608.50 ms | 16.128 ms |
ts-node + swc | 632.88 ms | 633.00 ms | 7.220 ms |
tsx | 322.75 ms | 322.00 ms | 3.650 ms |
Deno | 26.66 ms | 26.00 ms | 6.142 ms |
Bun | 16.40 ms | 16.00 ms | 1.760 ms |
These results, once again, make it easy to crown a winner of this benchmark: Bun leaves the competitors in the dust with a mean execution time of 16.4ms. The Node.js implementation takes about 10 times as long to execute the same code while the next fastest tool, Deno, is still about 60% slower than Bun.
Conclusion
To be honest, I was more than a bit underwhelmed by the performance of Node.js. And even if we ignore the numbers for a minute, one still needs to enable experimental features to run what I would consider to be a very basic TypeScript project and still does not get any type checking.
The missing type checking on almost all runtimes is a bit of a letdown as well.
Almost all the tools recommend running your code through tsc
before shipping
it to production. With their default settings, the following code always
executed without error:
function main(param: string = "World") {
console.log(`Hello, ${param}!`)
}
main(5)
Looking at this from a purely performance focused perspective, this makes sense. When viewing the tools as a TypeScript interpreter or REPL, as I understood them initially, it does not. I don't think that this is problem, per se, but I would like to see the tools being more upfront about this behavior.
The biggest takeaway from these tests, however, is the dominance of Bun when it comes to the execution performance. I'll admit that I might have slept on this tool for too long, but after its performance in this test I will definitely try to incorporate it into new projects of mine.