Blog

From Bun to Rust: rewriting VINlist's server in a week

Part 1 of 3 · Mateusz Mysiak ·

On September 23 I watched DHH's opening keynote at Rails World 2026. Showing what Rails' conventions take off a developer's hands, he said: "Look at all the things I'm not doing. Look at all the configuration I'm not writing." Later in the same talk he told the audience that 37signals is rebuilding HEY with a Rust backend. I turned the first line on my own stack. What do I carry only because it came with the tools, and what could I simply stop doing? Five days later VINlist's server was running on Rust.

Where we started

VINlist is a car marketplace built around the VIN: every listing is decoded from the car's own identification number, so a buyer sees the real make, model, engine and dimensions before anything a seller typed. Until the end of September the server was TypeScript on Bun, with React rendering pages on the server and again in the browser. It worked. It also meant 331 npm packages to install and trust, 4.2 MB of third-party JavaScript sent to visitors' browsers, and 469 MB of container images built on a full Linux distribution.

A port, not a rewrite

The rule I set was that the Rust server had to produce the same output as the TypeScript one before it could replace it. The code was split into small crates: pure VIN decoding with no I/O, the SQLite layer, the knowledge base loaders, the web service, an ingress proxy, the photo VIN reader and the operational tools. Each part was ported and checked against the running site:

  • A listing page rendered by Rust was byte-identical to the one React produced.
  • The browser's calls to the server kept their exact names and their cross-site request rule, verified against the built client.
  • 130 real VINs from visitors were decoded by both servers. 105 came out identical. The other 25 differed on purpose: the new decoder refuses to fill in an engine from the catalog when the VIN's own engine doesn't match any catalog row. The old one would happily turn a V6 Accord into a four-cylinder.

The switch happened on September 28. The next day the TypeScript server code was deleted.

Then the JavaScript toolchain went too

With the server in Rust, the rest of the JavaScript stack was there only for the browser. Over the next four days it went, piece by piece:

  • React. The server now renders every page completely, in the reader's language and currency. The browser gets small plain modules for the interactive parts. No framework, no hydration, no flash of English before a translation loads.
  • The wallet libraries. Signing in with a crypto wallet used to pull in most of those 331 packages. I wrote the wallet connection myself and kept the cryptography in audited Rust libraries, compiled to a 143 KB WebAssembly file.
  • The build. CSS and JavaScript are built by a small Rust program on the same engines the JavaScript tooling uses internally (Lightning CSS and Oxc), so the output didn't change.
  • The tests. The JavaScript unit tests run inside Boa, a JavaScript engine embedded in cargo test. Browser checks drive a pinned headless Chrome.

By October 2 there was no Node, no Bun and no npm anywhere in the project.

What changed

Same machine, same data, same method each time: a copy of the database, 20 warm-up requests, then 2,000 requests, 20 at a time.

Bun, Sep 27Rust, Oct 2
Listing page, median answer time227 ms2.2 ms
Listing pages per second, 20 at a time504,883
Cold start0.39 s0.10 s
npm packages3310
Third-party JavaScript in visitors' browsers4.2 MB0
Outside sites a page may contact198

The container images shrank too. Comparing the old web image with the new one alone would flatter Rust, because the photo VIN reader moved out of it into an image of its own, so here is the whole set:

Container imageBun, beforeRust, today
Web app303 MB: Bun on Debian 166 MB, Tesseract with its language data about 97 MB, the app and its packages about 40 MB16.7 MB: the server binary 14.8 MB, the site's own files 1.9 MB
Proxy166 MB: the same Bun on Debian, running a small script4.2 MB: one binary
Photo VIN readerinside the web app image85 MB: Tesseract, the libraries it needs and its English language data
All images469 MB in two images (303 MB on disk, since they share the Bun base)106 MB in three images

Two of the numbers in the first table need a fair reading. The listing page gap is partly work the old server repeated on every request and the new one does once. And Rust is not cheaper per unit of work here: in this test it spent about 31 ms of CPU per request against Bun's 9 ms. Its lead under load comes from using every core where Bun served on one thread, and from caching. The pages bound by database queries, like the statistics page, barely moved on either runtime.

What I'd tell someone about to do this

  • Make the old system the test. Byte-for-byte comparisons against production caught more than any test I would have thought to write.
  • Keep the differences you mean. The 25 VINs that decode differently are a list I can defend line by line. An unexplained difference is a bug until proven otherwise.
  • Measure after you ship. The first measurement on the live site found the new server losing speed under load. That story is in the next article.

All articles

Update available

A newer version of VINlist is ready.

This tab was opened before the update, so it is still running the older version.

Refreshing now will discard anything unsaved — a listing you’re writing, or filters you’ve set.