Why an incremental port beat a rewrite

When we started planning the migration of Turborepo from Go to Rust, a full rewrite was tempting. Rewrites are simpler to write and ship because you don't have to make old and new code work together, and you get a clean slate free of technical debt. But the downsides were decisive.

A rewrite would have meant halting feature development, or spending effort chasing a moving target as the old codebase continued to grow. There was also no guarantee the new version would match the old one feature-for-feature and edge case-for-edge case — a major risk for user trust. And a rewritten codebase would have contained a large amount of unused code, which, even with tests, can harbor bugs. We wanted each new piece of Rust code to be exercised by real use from day one.

Instead, we chose an incremental port. That means moving code piece-by-piece, running new and old code side by side, with the goal of keeping behavior identical for every migrated chunk. The tradeoff was added complexity: we needed Go and Rust to interoperate safely, which initially slowed developer velocity. But it let us keep shipping features throughout the process, and we determined that was the right compromise.

Starting small: global turbo

The safest way to begin was with a small, new feature written entirely in Rust. This let us integrate Rust into the build process and interact with existing Go code as little as possible while we figured out the mechanics. The first feature we chose was global turbo, which lets users install Turborepo as a globally available command. A global install looks for a local turbo in the repository, executes it if present, and otherwise falls back to the global binary.

Global turbo was implemented as what we called "the Rust shim": a small Rust binary that wraps the existing Go code. The Go portion was compiled via CGO as a C static library and linked into the Rust binary. Since the feature only needed a few capabilities — reading configuration and navigating the file system — it was also a good test of how cleanly we could perform the FFI integration.

Porting the CLI parser

Global turbo required parsing a few command-line arguments like --cwd (which sets the current working directory), so parsing was the next logical piece to port. We use the clap crate, which lets us define a data type for arguments and automatically generates the parser. Once Rust handled parsing, we needed to send the results to the Go side.

C is the standard language for foreign function interfacing, but we were wary of defining many cross-platform C types that would work in both Rust and Go. Instead, we serialize the arguments to JSON and pass the string over the FFI boundary. JSON serialization has overhead, but the arguments struct is a few hundred bytes, so performance impact is minimal. On the Rust side we use serde for serialization; Go already used JSON, so deserializing the string was straightforward.

Shipping a hybrid binary — and hitting walls

With two features ported, we set out to ship our first hybrid Go-Rust release. That meant verifying the binary worked across the operating systems and Linux distributions we support. Testing immediately surfaced the predictable problems.

Windows: two toolchains

Windows has two main toolchains: Microsoft Visual C++ (MSVC) and MinGW (Minimalist GNU for Windows). Go only supports MinGW, while our Rust code was built with MSVC. This caused runtime errors, but the fix was straightforward: we switched our Rust toolchain to MinGW.

Next came path handling. Windows has multiple path concepts, including Universal Naming Convention (UNC) paths. When you ask Windows to canonicalize a path, it returns a UNC path — but UNC paths aren't accepted everywhere, even by Windows itself in some cases. We solved this with the dunce crate, which canonicalizes paths without returning a UNC prefix.

Alpine Linux: static binaries are not a silver bullet

Alpine Linux, which we use at Vercel for lightweight build containers, is where the real complications lived. Alpine doesn't include glibc, and binaries typically assume it's present. Compatibility packages like gcompat or libc6-compat didn't work for us because the glibc version Rust required was too new for our supported targets.

Our solution was to compile Turborepo as a fully static binary, packaging our own C standard library using musl (static glibc isn't an option due to licensing). This worked well for Rust and Go individually: for Rust, you can set the C standard library in the target specification (aarch64-unknown-linux-musl vs. aarch64-unknown-linux-gnu), and Go doesn't use a C standard library by default.

But when we ran the static binary, we got a segmentation fault. The stack was corrupted, and the fault appeared to come from the Go runtime itself. After significant searching, we tracked down a seven-year-old GitHub issue: Go cannot be compiled as a C static library with musl.

The split-binary solution

We had to reconsider our approach. After further deliberation, we decided to compile the Go code and Rust code as two separate binaries. The Rust code calls the Go code using the CLI, passing arguments serialized as JSON. Since the args are small, the performance cost of spawning a process is negligible — and because we were already using JSON serialization, the code changes were minimal. We only had to alter how Rust delivered the JSON string to Go: instead of through a C library call, through standard process execution.

This approach let us ship the first hybrid Go-Rust release. Version 1.7.0 of turbo, released with this compilation strategy, marked the first time our incremental port made it into production. Since then, we've continued moving more of Turborepo's functionality to Rust, with this architecture serving as the foundation for the ongoing migration.

Lessons from a large-scale rewrite

Moving a mature codebase between languages is rarely just a mechanical translation. The Turborepo team surfaced several lessons during its Go-to-Rust port that apply to any serious rewrite effort.

Serialization shrinks the FFI surface

The team's first major insight was that serialization formats serve as a powerful tool for interoperability. By exchanging JSON—a format with mature support in both Go and Rust—the FFI layer stayed deliberately small. That decision paid off when the project shifted from a single linked binary to two separate binaries: the switch was straightforward because the boundary between languages was already minimal.

The tradeoff is performance. Serialization and deserialization add overhead, so this approach only works when payloads are small or when the performance cost is acceptable for the specific use case.

Porting demands disciplined preparation

Incremental porting is viable, but it hinges on rigorous testing. The team encountered subtle bugs that were only caught through extensive automated and manual test suites, which are published in the project's GitHub workflows.

Testing serves a second critical purpose: it pins down behavioral details that seem unimportant during the first implementation but become breaking-change hazards during a port. Edge cases in CLI argument parsing or the precise order of configuration loading are exactly the kind of details that must be locked down before migration begins. Writing tests before starting the port gives you a known specification to validate against.

Cross-platform releases amplify complexity

Release engineering across multiple platforms and toolchains is one of the hardest parts of a rewrite. Each operating system, language runtime, and compiler introduces its own quirks, and every added layer of compatibility creates another place where something can go wrong.

Strategic payoff justifies the cost

Despite the debugging challenges, careful planning, and intense testing, the port has proven strategically sound. Throughout the migration, the team continued shipping features, fixing bugs in existing functionality, and serving users without a pause in delivery.

The effort also delivered concrete results. This week alone, Turborepo saved 5,742 hours of time for product engineers and CI machines at Vercel. For those wanting to replicate that workflow, the team has documented how to get started with Vercel Remote Cache in a companion piece on the Vercel blog.