# We replaced "Rust everywhere" with one Rust library

We started Hafen with Rust everywhere on the backend: the API, the workers and the libraries. On 24 October we changed that decision. Every process is moving to Node, and Rust becomes one native library for the work done once per page.

Speed wasn't what forced the change. SDKs were.

The problem wasn't Rust

The Rust code was fine. The trouble started every time we needed to talk to something else.

Agent infrastructure sits on tools that ship TypeScript first, and often only TypeScript: MCP, the AI SDK, E2B, turbopuffer, DuckDB's newest client. In Rust, each of those was a beta, a community crate or a REST client we'd have to write ourselves.

So the choice looked like this. Keep Rust, and own a client for every vendor. Or find out how much speed we'd actually lose in Node.

We measured before deciding

We ran the real extraction code over 21,529 Common Crawl pages on one Ryzen 9700X:

[ PAGES PER SECOND ]
  1. Rust, native2,819
  2. Node + NAPI2,707
  3. Pure JS266

Node calling Rust produced identical output and never blocked the event loop for more than 6 ms. The pure JavaScript run crashed 65 times, and Bun's JavaScript run didn't finish because it ran out of memory.

Node calling Rust kept 96% of the native speed. Pure JavaScript was about ten times slower and crashed on real pages. That one result made the decision simple: don't choose between them. Keep the Rust code and call it from Node.

We tested the same shape on our fetch pipeline, with 10,000 URLs across 400 fixture hosts:

[ 10,000 URLS, 400 FIXTURE HOSTS ]
Rust CLINode + Rust
  • Time60.1 s60.1 s
  • Success99.63%99.63%
  • Politeness violations00
  • Memory66 MB305 MB
  • CPU1×about 3×

Node isn't free, though. We accept the extra memory and CPU because more memory is cheaper for us than maintaining a client for every vendor.

We checked Go too

We ran Go over the same pages at 16 threads:

[ SAME CORPUS, 16 THREADS ]
Pages/sMemory
  • hafen-extract (Rust)2,7733.6 GB
  • go-readability2,7937.1–7.4 GB
  • go-trafilatura1,8907.1–7.4 GB

On speed, go-readability tied with us, which makes sense because both are Readability ports. Go held about twice the memory, and calling it from Node would add a second runtime and copy data across cgo on every call. A tie wasn't a reason to take on that cost.

[ BACKEND LANGUAGE ]
accepted24 Oct
  • Node + one Rust library96% of native speed, with the TypeScript SDKs
  • Rust everywherea client to own for every vendor
  • Pure JavaScriptabout ten times slower, and it crashed on real pages
  • Go through cgoa tie on speed, a second runtime and twice the memory
Chose Node + one Rust library over 3 other options.

The rules we kept

A language decision only lasts if it's written as rules:

  • Rust must earn its place. Code goes into Rust only if it runs once per page or document, or if a benchmark on realistic data shows Node missing a budget.
  • There are no Rust services. Anything that earns Rust joins the one library.
  • Rust does no I/O to our own systems. It never touches Postgres, Valkey or NATS. It returns data, and Node writes the rows.
  • Panics must not kill the process. A panic in an async export rejects its promise and the job retries. A panic in a sync export without catch_unwind aborts all of Node. We found that by testing it, so now it's a rule.

What it cost

We had already merged Rust services, so this means porting them and deleting that code. Each ported PR has to pass the checks of the PR it replaces, which is why those checks were worth writing as commands.

If we started again, we'd ask about the vendors before the language. For agent infrastructure, the SDK you'll depend on next year matters more than the benchmark you can win today.

These numbers come from one dev box and local fixtures, not production traffic. Run your own on your own data.