Moving a Backend from Rust to Go, and Why Build Time Decided It
Rust gave us safety we rarely needed and a compile loop we fought every day. Here is the honest tradeoff, with numbers, that pushed a service to Go.
We rewrote a production backend from Rust to Go, and the deciding factor was not runtime speed, memory use, or safety. It was the compile loop. A service you change ten times an hour lives and dies by how fast you can see the change. This is the full accounting of what we gave up, what we got back, and how we decided which parts of the system to leave in Rust.
None of what follows is a complaint about Rust. Rust did exactly what it promises. The point is narrower: the thing Rust is best at was not the thing this particular service needed most, and the price it charges was one we paid on every edit.
The feedback loop is most of the job
Most backend work is not writing new code from a blank file. It is changing one line, running it, reading the error, and changing it again. Ten, twenty, fifty times in a row. If that round trip takes forty seconds, a morning of debugging is mostly waiting. If it takes four, you stay inside the problem and your short-term memory of what you were testing survives.
That last part matters more than raw seconds. There is a threshold, somewhere around ten seconds, where you stop waiting for the build and start context-switching. You alt-tab, you read a message, you lose the thread. When you come back you have to reload the whole mental picture of what you were doing. A four-second build keeps you in the chair. A forty-second build turns every test into a small interruption, and interruptions do not add, they multiply.
For a service that is edited constantly, the compiler is not a background tool. It is the single most-used part of your day. We had been treating its speed as a minor inconvenience when it was actually setting the pace of all our work.
The bugs we shipped were not the bugs Rust prevents
Rust's type system and borrow checker eliminate whole categories of defect: data races, use-after-free, iterator invalidation, null dereferences. These are real, expensive bugs, and in the right codebase that guarantee is worth almost any price.
The service in question was not that codebase. It was I/O bound glue: HTTP handlers, database calls, a translation queue, some JSON in and some JSON out. Look at what actually broke in production over a few months and the list was consistent:
- A query with the wrong filter shape that returned the wrong rows.
- A JSON field that was optional in one place and required in another.
- An off-by-one in pagination.
- A timezone assumption that was correct locally and wrong in the deployed region.
Not one of those is a memory-safety bug. Rust would have compiled every one of them. We were paying for a guarantee against data races in a service that had almost no shared mutable state to race over, because nearly everything was per-request and stateless. The insurance was real, but we were insuring against a risk we did not carry.
Where the time actually went
The pain was not the cold build. You pay that once a day, maybe, and you can go get coffee. The pain was the incremental build after a one-line edit, because that is the one you pay all day long.
Here is the shape of it, as ranges rather than precise benchmarks, because the exact numbers depend on your machine and your dependency graph:
| Change | Rust (incremental) | Go |
|---|---|---|
| Edit one handler body | tens of seconds | about a second |
| Touch a shared type | a large recompile | seconds |
| Add a dependency | minutes | seconds |
| Full clean build | many minutes | seconds |
Two things drove the Rust numbers. The first is monomorphization: generic code is compiled fresh for each concrete type it is used with, which produces fast binaries and slow builds. The second is the dependency graph. Pull in a few crates that lean on procedural macros, and a large fraction of your build time is spent expanding and compiling code you never read. A one-line change to a widely-used type can invalidate a big chunk of the cache and pull a long recompile behind it.
Again, this is not Rust misbehaving. Monomorphization is why the runtime is fast. Proc macros are why the ergonomics are good. These are deliberate trades that favor the produced binary over the compile loop. For a service we edited all day, we were on the wrong side of that trade.
What Go traded away, and why it was worth it
Go is a smaller, blunter language, and moving to it meant losing real things. Being honest about the losses is the only way to make the tradeoff credible.
We lost proper sum types. Go's answer to a tagged union is an interface plus a type switch, which the compiler does not check for exhaustiveness. We lost the ? operator and got back if err != nil on what feels like every third line. We lost a borrow checker that would have caught a class of concurrency mistake at compile time, and had to lean on the race detector and code review instead. Generics arrived in Go late and are still less expressive than Rust's traits.
Here is what we got for those losses:
- A compiler fast enough that the build stopped being something you think about. The edit-run-read loop dropped below the interruption threshold and stayed there.
- One obvious way to do most things. There is roughly one way to write a loop, one way to handle an error, one formatting standard enforced by a tool nobody argues with. That uniformity matters more than it sounds, especially when a large amount of the code is written or edited by an AI assistant, because there is less room for a plausible-looking but unusual construct to slip in.
- A standard library that already had the HTTP server, JSON, context propagation, and timeouts we needed, without pulling a dependency tree behind each one.
The verbose error handling turned out to be a smaller cost than expected, for a specific reason: it is local and skimmable.
// The whole error style in one function. Boring to write, fast to read.
func (s *Server) renderHome(ctx context.Context, lang string) (*Page, error) {
posts, err := s.store.ListPublishedByLang(ctx, ListQuery{Lang: lang, Limit: 20})
if err != nil {
return nil, fmt.Errorf("list posts for %s: %w", lang, err)
}
count, err := s.store.CountPublishedByLang(ctx, lang)
if err != nil {
return nil, fmt.Errorf("count posts for %s: %w", lang, err)
}
return s.buildPage(lang, posts, count), nil
}
Every error site is right there next to the call that produced it, wrapped with context that names what failed. When something breaks in production, the error string reads like a stack of what-was-happening. A slow build, by contrast, is not local. It blocks the whole team on every change, and you cannot skim your way past it.
The migration was boring on purpose
We did not do a big-bang rewrite. The service was split behind an interface, and we moved it one bounded piece at a time, keeping the Rust version running until the Go version served the same traffic with the same output. Boring is the goal for a migration. The interesting parts are where bugs live.
Two things made the port straightforward. First, the data model did not change; only the code around it did, so we could compare the two implementations against the same database and diff their responses. Second, Go's standard library covered the surface area we needed, so most handlers were a near-mechanical translation rather than a redesign. The parts that were not mechanical, mostly error handling and concurrency, were exactly the parts worth doing slowly by hand.
When we would still reach for Rust
This is a tradeoff, not a verdict, and treating it as a verdict is how people end up with the wrong tool twice. Rust earns its compile time back many times over in the right place:
- A CPU-bound core where every allocation and every branch counts.
- Code with genuine shared mutable state and real concurrency, where a data race is a live risk rather than a theoretical one.
- A parser, a codec, a numeric hot path, anything where a subtle memory bug would be both likely and catastrophic.
We kept exactly those parts in Rust. Media transcoding and a couple of hot numeric routines never moved, because for them the compiler is paying for itself on every run. The service layer around them, the part that is mostly waiting on the network and the database, is where Go belongs.
The rule we settled on
Match the language to where the bug risk actually lives, not to where you wish it lived.
For safety-critical, CPU-bound code, pay the compiler and let it catch what humans miss. For I/O-bound service code you edit all day, the dominant risk is not a data race, it is a slow feedback loop that keeps you from finding the logic bug in front of you. There, buy back the loop.
That single question, where does the bug risk live, is what moved this service to Go and kept its hot core in Rust. The answer will not be the same for your system. The question should be.
Related posts
Hot-Swapping Cron Schedules with MongoDB Change Streams
Hardcoded cron means a redeploy every time a schedule changes. Store schedules in the database and have a daemon react to edits with change streams. Here is the design and its failure handling.
Load Balancing a GPU Worker Pool with Redis Keyspace Notifications and Leases
GPUs are expensive and run one heavy job at a time, so round-robin routing stalls behind busy workers. Here is a busy-aware scheduler built from Redis leases and keyspace notifications.
Lease Locks with CAS and Heartbeat for Single-Runner Jobs
A plain lock deadlocks forever when the holder dies. A lease expires. Here is how to build one with compare-and-swap acquisition and a heartbeat, and the failure modes that decide the design.
Deterministic ULIDs for Idempotent Upserts
When a database forbids automatic write retries, every write must be idempotent on its own. A content-derived id makes that free. Here is the derivation and the one invariant that keeps it safe.
The retryWrites=false Trap in Firestore's MongoDB Compatibility
Firestore speaks the MongoDB wire protocol, but rejects retryable writes. If your driver defaults to on, writes fail in a way that looks random. Here is why, and the fix.