Building an API in Rust and Node
I thought I'd jot down some brief notes about a recent project replicating a Node API I wrote in Rust.
If you'd like to skip to the source instead, here's the GitHub repo.
TLDR
- The Rust implementation was around 13 times faster than the Node implementation
- The Rust developer experience was quite nice thanks to the compiler, cargo and rust-analyzer
- Setting up TypeScript and debugging ESM-compatible libraries for the Node implementation was not so nice
The API
The API receives markdown and returns HTML via JSON strings. At runtime, the program should validate input, handle errors, perform the transformation and sanitize the output HTML.
Node implementation
For brevity, I'll condense parts of the Node API into a single snippet:
;
;
;
;
;
;
;
;
// Server
;
// Routes
'/markdown-to-html', request, response
;
3000;
'Listening on http://localhost:3000';
// Handler transforming markdown to HTML
It's a fairly typical Node API, using ES modules with TypeScript and a few carefully selected dependencies:
- pure-http as a slight extension of Node's builtin
http
module - marked for parsing markdown
- xss for HTML sanitization
- zod for schema validation
All in all, I'm happy with the design tradeoffs and shared more on that in the repo. It was time consuming to find and debug ESM-compatible libraries, which is expected. A particular sour spot was replacing Jest, which has too many dependencies and doesn't play well with TypeScript and ESM. I settled on uvu, which I found much more acceptable.
Rust implementation
I based the Rust implementation off of examples in the Warp repo:
async
Dependencies include:
- tokio as a networking runtime
- warp as a web server abstraction
- pulldown-cmark for parsing markdown
- ammonia for HTML sanitization
- serde for JSON serialization
I can't comment on the design tradeoffs since I'm not as familiar with the Rust ecosystem, but I can say that I found the crates well documented with useful examples. The Rust compiler was strict and offered helpful errors. Cargo did exactly what I wanted it to do, no more, no less.
Benchmarking
I used the ApacheBench tool to run benchmarks for each of the endpoints. Here's the bash script:
# Test concurrency with ApacheBench
# See https://httpd.apache.org/docs/2.4/programs/ab.html
# -p indicates POST
# -T sets Content-Type header
# -c is concurrent clients
# -n is the total number of requests
And the JSON payload, which has the core Gruber markdown syntax:
The tool outputs a lot of information, but the requests per second metric stood out to me in particular:
- The Node implementation can handle roughly 0.9k to 1k requests per second
- The Rust implementation can handle roughly 13k to 14k requests per second
That's a big difference.
Of course this an imperfect test with many variables, but at the very least, it's delicious food for thought.
This performance outcome, combined with the pleasant Rust developer experience, is encouraging. Now on to the next experiment, web sockets in Rust!
Thanks for reading! Go home for more notes.