Running language servers and linter using Bun instead of Node
Bun is a new JavaScript runtime claiming to be faster. Would it help if we used it to run tooling we’d like to see running faster - ESLint, language servers?
Primer: How NPM runs packages
Remember that whenever you run npx eslint, npm goes to ./node_modules/.bin/eslint and runs it as if it were a regular program. It will respect the shebang at the top of the file. The files inside .bin/ don’t even need to be JavaScript files. It could be a shell script and as long as there’s a proper #!/bin/bash shebang at the top, NPM would run it anyway.
The .node_modules/bin/eslint file I mentioned appears there because ./node_modules/eslint/package.json’s bin property tells it to, and NPM just downloads .
The same happens if you install a package globally using npm install -g eslint and run eslint (instead of npx eslint).
Contents of ./node_modules/.bin/eslint
Notice the shebang at the top.
$ cat ./node_modules/.bin/eslint
#!/usr/bin/env node
/**
* @fileoverview Main CLI that is run via the eslint command.
* @author Nicholas C. Zakas
*/
/* eslint no-console:off -- CLI */
"use strict";
// must do this initialization *before* other requires in order to work
if (process.argv.includes("--debug")) {
[...]
Contents of ./node_modules/eslint/package.json’s bin property
$ cat ./node_modules/eslint/package.json
{
"name": "eslint",
"version": "8.54.0",
"author": "Nicholas C. Zakas <nicholas+npm@nczconsulting.com>",
"description": "An AST-based pattern checker for JavaScript.",
"bin": {
"eslint": "./bin/eslint.js"
},
[...]
}
Primer: Processes
The default behavior
It’s helpful to run npx eslint and inspect what process it actually runs.
Normally we’d just do npx eslint | ps -aux | grep eslint but there’s a layer of wrappers that npx adds that makes it hard to get that way.
It’s easiest to just run npx eslint . in a larger project (so it runs for a while), then open htop and then search using the / key for “eslint”.
You can also just open another terminal and run watch -d -n 0.1 ps -aux | grep eslint to get a live list of processes with the word “eslint”.
When you’re done, you should find a line like /usr/bin/node [dir to project where you ran eslint]/node_modules/.bin/eslint [arguments you passed to eslint]
Swapping to Bun
So to run with Bun instead of Node, we need the process to be /usr/bin/bun and not /usr/bin/node.
You can just run the below in your terminal to do that:
$ bun [dir to project where you ran eslint]/node_modules/.bin/eslint [arguments to pass to eslint]
Tool tests
ESLint
ESLint is a linter for (Java|Type)Script code. Linting a large project can take more than ten seconds, especially if you’ve installed a lot of custom linting rules.
For testing, I downloaded the large repository of freeCodeCamp (https://github.com/freeCodeCamp/freeCodeCamp) at commit c12a1bb6553d59a36328dded934763259734fb18
Here’s a sample script that will run the tests for you (you can copy paste, no need for file):
cd $(mktemp -d) && \
git clone https://github.com/freeCodeCamp/freeCodeCamp && \
cd freeCodeCamp && \
git checkout c12a1bb6553d59a36328dded934763259734fb18 && \
npm install && \
echo "\nWait for BUN results:" && \
time bun ./node_modules/.bin/eslint . -o /dev/null; \
echo "\nWait for NODE results:" && \
time node ./node_modules/.bin/eslint . -o /dev/null
Results
For me, the outpt is:
Wait for BUN results:
bun ./node_modules/.bin/eslint . -o /dev/null 40,73s user 2,12s system 150% cpu 28,462 total
Wait for NODE results:
node ./node_modules/.bin/eslint . -o /dev/null 57,35s user 1,49s system 178% cpu 32,941 total
Summary
So Bun turns out to be ~17 seconds faster.
TypeScript Language Server
This is the program giving you smart editing features like “Go To Definition”, “Rename” in your editor. It can be slow to respond in large projects.
You can find the Bun + tsserver benchmark here: https://git.0x2f.pl/rt/bun-tsserver-benchmark
In the ipc.ts file in the above repository:
- We launch
tsserverin IPC (Inter Process Communication) mode.
It’s one of the features of tsserver. This means that instead of talking to the server usingstdio, we use a simple IPC event API. This is more because it’s easier to implement IPC than stdio communication. - We tell
tsserverto openapi.ts(a huge 40k line .ts file) - Then we ask
tsserver200 times to scan theapi.tsfile for “diagnostics” (“x is not defined” etc.)
The name of the command sent totsserverisSemanticDiagnosticsSync. This command was picked because it seemed like it’d be the most computationally intensive given the size ofapi.ts - We calculate the average response time and print it in the console
You need to either run bun ipc.ts or npx ts-node ipc.ts
Results
- Bun: avg. 529 ms/request
- Node: avg. 474 ms/request
- ts-node: avg. 506 ms/request
Summary
The differences aren’t that large.
Summary
Why does ESLint have a large speedup, but tsserver doesn’t
A good starting point would be to profile the execution of the ESLint and tsserver benchmarks. That would visualize
where do the programs spend most of their time. If certain Node APIs are used more in ESLint, it could point to an explanation - some APIs could be faster in Bun.
You can run a profile by running node with the --inspect flag, opening the Chromium devtools and going to the Performance tab.
You could also look at Bun’s benchmarks1 to see what it’s best at. Perhaps the Bun IPC implementation isn’t quite up to speed yet2, 3.
Testing different tsserver commands
Getting the communication with tsserver to work took some effort, so I didn’t feel like making the benchmark more robust.
I only tried SemanticDiagnosticsSync, but maybe Bun will shine more in different ones.
The list of commands supported by tsserver is rather long4,
but I recommend taking a closer look at the ones whose arguments use or extend the FileRequestArgs5 interface.
Those have the biggest chance of being the slowest, as they operate on a whole file rather than a segment of it.
Plus, it’s easier to work on a whole file than figuring out all the symbols in a file and their offsets.
-
One thing I’ve noticed is that if you increase the number of requests from 200 to 1000 and run the benchmark with Bun, tsserver will just stop responding - it won’t even send a single message. ↩︎
-
https://bun.sh/guides/process/ipc - “Note — This API is only compatible with other bun processes. Use process.execPath to get a path to the currently running bun executable.” ↩︎
-
https://github.com/microsoft/TypeScript/blob/main/src/server/protocol.ts#L25-L177 ↩︎
-
https://github.com/microsoft/TypeScript/blob/main/src/server/protocol.ts#L292 ↩︎