Having decided to move Turborepo from Go to Rust, Vercel's team rejected a ground-up rewrite. Instead they ran both languages at once, porting piece by piece behind a Rust entry point they call the Rust-Go-Rust sandwich -- and hit a segfaulting Alpine Linux binary along the way.
Two languages, one command line, and a seven-year-old GitHub issue that explained why the binary kept segfaulting on Alpine.
VERCEL·2023·LANGUAGE / RUST / GO / FFI
System stress over timeBreach at T+3
Incremental port, not a rewriteApproach
v1.7.0First hybrid release
5,742 hours (product eng + CI)Time saved, one week
7-year-old GitHub issueAlpine bug's age
THE SITUATION
Rust was decided. Now someone has to move 70,000 lines of Go
Vercel’s team had already decided to move Turborepo from Go to Rust. The open question was how. Turborepo was a working, widely-installed CLI, not a greenfield project — so whatever the porting strategy was, it couldn’t ask users or the team to simply stop while the migration happened.
THE COMPLICATION
Why a full rewrite was rejected
The team laid out three reasons a ground-up rewrite doesn’t work for software like this. Halting all feature work to rewrite means chasing a moving target as the old codebase keeps growing underneath you. A rewrite doesn’t guarantee a better experience for users and often introduces breaking changes along the way. And code left behind during a rewrite doesn’t just sit there harmlessly — unused code becomes, in their words, “a breeding ground for bugs.”
THE QUESTION
How do you run two languages doing the same job at once, without the seams showing?
So the constraint was: port incrementally, keep shipping features the whole time, and make sure Go and Rust code can call each other cleanly enough that users never notice the seam. That meant answering a narrower, harder question — what’s the actual mechanism that lets a Rust binary hand off to Go code, and back, across every platform Turborepo ships on?
THE ANSWER
The Rust-Go-Rust sandwich, and the Alpine binary that wouldn't behave
The team built what they call the Rust-Go-Rust sandwich: Rust sits at the entry point and decides, command by command, whether to run its own Rust implementation or hand off to the existing Go code. The first feature ported this way was global turbo, a machine-wide install command — the “Rust shim” wrapping the existing Go implementation, compiled via CGO into a C static library. The CLI argument parser came next, rebuilt in Rust with the clap crate. Rather than build complex cross-platform C types to pass arguments like --cwd between the two languages, they serialized arguments to JSON strings, leaning on serde in Rust and the JSON handling Go already had.
That approach survived Windows — where Go’s exclusive use of the MinGW toolchain clashed with Rust’s initial MSVC build until Rust switched to MinGW too, and where Universal Naming Convention paths needed the dunce crate to canonicalize cleanly. Alpine Linux was worse. Alpine has no glibc, so the team tried compiling statically against musl instead — and the Go runtime “would return a segmentation fault” with “a corrupted stack.” The cause turned out to be a seven-year-old GitHub issue: Go cannot be compiled as a C static library with musl at all. The fix was architectural — stop linking Go and Rust into one binary, and instead compile them as two separate binaries, with the Rust executable calling the Go binary over the CLI and passing the same JSON-serialized arguments they’d already built. Version 1.7.0 shipped as the first hybrid Go-Rust release using that strategy.
THE TAKEAWAY
What incremental porting actually costs
The team was candid about the price of this approach: cross-platform, cross-language release engineering is “extremely challenging,” and JSON serialization added performance overhead they accepted because the argument payloads were only hundreds of bytes. Incremental porting also demands heavy automated and manual testing up front, to pin down exact edge cases before a single line moves. Despite all of it, they maintain the incremental port “has proven to be the correct choice for us strategically.” By the week this post published, Turborepo had saved 5,742 hours of product-engineering and CI machine time at Vercel in that week alone.
Porting a live system in place is slower and messier than a clean rewrite — but it never stops shipping, and that’s the whole trade.
Part 2 of a 3-part trilogy on the same migration: the decision, the incremental porting mechanics, and the finished migration.
A plain-language, AI-drafted and human-edited retelling of the article published on vercel.com,
reorganized and explained in our own structure and words, with original analysis in the editor's
note above. The facts, numbers, and decisions belong to the original author and are not altered.
For the full depth, read the source.