← Back to blog

The Rewrite-in-Rust Trap

6 min read
architectureperformanceengineering-judgmentplatform

A team I know spent four months rewriting their event-ingestion service in Rust. The pitch was the usual one: garbage collection pauses were causing tail-latency spikes, and a memory-safe systems language would let them get closer to the hardware. They shipped it. Tail latency didn't move. The bottleneck, it turned out, was a synchronous call to a downstream service that blocked on every request — a problem that existed in the old code, existed in the new code, and had nothing to do with which language wrote it.

That's the trap. A rewrite feels like progress because you're producing a lot of visible work — new repo, new benchmarks, a language that's fun to write in. But it only pays off if the thing you're rewriting away from is actually the bottleneck. Most of the time, it isn't. The bottleneck is a query, a lock, a network call, or an architecture that would produce the same tail latency in Rust, Go, or COBOL, because the problem was never CPU-bound in the first place.

What a real dead end looks like

Some rewrites are the right call, and it's worth being precise about what separates them from the vanity kind. Bun rewriting parts of the JavaScript toolchain in Zig, or the various Postgres-adjacent projects rewriting hot-path components in Rust, share one trait: they had already profiled the existing system, found the actual hot loop, and confirmed the language's runtime characteristics — not its syntax, its runtime characteristics — were the ceiling. Node's startup time and single-threaded event loop are architectural properties of the runtime; no amount of clever JavaScript gets around them if your product is a CLI tool that needs to start in milliseconds thousands of times a day.

That's a genuine architectural dead end: the current system's fundamental constraints, not its implementation details, are the limiting factor. You can tell because the case for the rewrite survives someone asking "what specifically, measured, can the old system not do?" and getting a concrete answer — not "it feels slow" or "the codebase is a mess," but a number, from a profiler, attached to a workload that matters to the business.

The second-system fantasy

Fred Brooks named this decades ago, and it hasn't gotten less true: the second system is where you fix every real and imagined flaw of the first one, and it always seems simpler and faster to build than it turns out to be, because the first system's complexity wasn't accidental — it was earned. Every weird conditional, every defensive check, every "why is this here" branch encodes a bug someone hit in production and fixed. A rewrite starts by deleting all of that context, because nobody remembers why it's there, and then spends the next year rediscovering each one the hard way.

I've watched this happen with a service that handled webhook delivery. The original code had an ugly retry-with-backoff branch that looked like it could be simplified. It survived three rewrite attempts before someone actually asked why it existed: a specific customer's endpoint returned 200 with a body that looked like an error, and someone had special-cased it two years earlier. That kind of scar tissue doesn't show up in a design doc. It shows up in an incident channel from 2024 that nobody thought to search before starting over.

The tell that you're rationalizing, not diagnosing

The framework I use before agreeing to a rewrite is simple: can you point to a measurement, taken on the current system, that shows the bottleneck is structural rather than incidental? "Structural" means changing the implementation without changing the architecture wouldn't help — the language, the data model, or the concurrency model is load-bearing for the problem. "Incidental" means a targeted fix — an index, a cache, an async boundary moved one layer over — would close most of the gap.

If nobody has actually profiled the thing, that's the answer already: you don't know yet, and a rewrite is a guess dressed up as a plan. If the argument for rewriting leans on adjectives — "cleaner," "more maintainable," "the right abstraction" — rather than numbers, you're pricing in a rewrite to solve a taste problem, and taste problems are much cheaper to solve with a refactor than a from-scratch reimplementation, because a refactor can be done in pieces and validated against production behavior at every step. A rewrite, by contrast, is validated once, at the end, against everything at once — which is exactly the moment you discover the retry-with-backoff branch you deleted mattered.

What actually earns the rewrite

When the diagnosis is real, the rewrite still needs a containment strategy, because "everything is better once the new system replaces the old one" is the same optimism that got you into this. Ship it behind the old system for a while. Run both in parallel and diff the outputs. Migrate one traffic shape at a time, starting with the one your profiler flagged as the actual bottleneck, and confirm the numbers move before you migrate the rest. If they don't move, you've learned your diagnosis was wrong for a fraction of the cost of the full rewrite, and the old system is still there to fall back on.

The pattern that fails is the big-bang cutover: months of work, no production traffic until the end, and a launch date that's really a bet that nothing in the old system's accumulated behavior mattered. Sometimes that bet pays off. More often it's how a four-month Rust rewrite ships and the tail latency graph doesn't move, because the team fixed a language problem they didn't have and left the blocking network call exactly where they found it.

The takeaway

A rewrite is a tool for a specific diagnosis — a runtime's fundamental constraints, confirmed by measurement, are the ceiling on a problem that matters. It is not a tool for "this codebase makes me unhappy" or "a faster language sounds appealing," even when the faster language is real and the unhappiness is justified. Profile first, name the specific number you expect to move, and if you can't say what that number is before you start, you're not diagnosing an architectural dead end. You're rationalizing a second system.