==========
== 0x2f ==
==========

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:

  1. We launch tsserver in IPC (Inter Process Communication) mode.
    It’s one of the features of tsserver. This means that instead of talking to the server using stdio, we use a simple IPC event API. This is more because it’s easier to implement IPC than stdio communication.
  2. We tell tsserver to open api.ts (a huge 40k line .ts file)
  3. Then we ask tsserver 200 times to scan the api.ts file for “diagnostics” (“x is not defined” etc.)
    The name of the command sent to tsserver is SemanticDiagnosticsSync. This command was picked because it seemed like it’d be the most computationally intensive given the size of api.ts
  4. 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.


  1. https://github.com/oven-sh/bun/tree/main/bench ↩︎

  2. 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. ↩︎

  3. 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.” ↩︎

  4. https://github.com/microsoft/TypeScript/blob/main/src/server/protocol.ts#L25-L177 ↩︎

  5. https://github.com/microsoft/TypeScript/blob/main/src/server/protocol.ts#L292 ↩︎