What the published migrations say

Uber's account of its API gateway is one of the most detailed published Node.js to Go migrations. Grab, which was moving off a Rails and Node.js stack, and Dropbox, which moved from Python, are other moves to Go. They appear here because the same decisions recur, whatever language a team leaves.

Uber's edge gateway. In a 2020 post on its Edge Gateway, Uber describes its second-generation API gateway, a Node.js system that peaked at 800,000 requests per second and had become one of its largest Node.js applications. The problems it lists are organizational as much as technical. Upgrading 2,500 npm libraries to a newer Node.js version kept getting harder, 50,000 tests ran on every change, and onboarding engineers onto its asynchronous Node.js patterns slowed teams down. The rest of Uber had settled on "Golang and Java as the primarily supported languages" and was adopting gRPC, a remote procedure call framework.

In the third generation, a single standalone Go service replaced the edge layer. Uber reports that the move "improved our resource utilization and request/core metrics significantly," without giving figures. The move took two years.

Grab's passenger APIs. Grab, the Southeast Asian ride-hailing and delivery company, published a 2016 account of a zero-downtime migration of its passenger app APIs from Rails to Go. The Go server received shadowed copies of real production requests while the Rails server kept answering the app. An engineer wrote a JSON tokenizer to compare the two responses, and mismatches went to metrics and to a Slack channel. Endpoints moved one at a time, and any unforeseen bug meant an instant rollback.

The team's most useful admission is about rollout units. It first ramped traffic by percentage, then found one endpoint so busy that "a mere single percent of its requests dwarfed the full load of 5 others combined." It switched to increments in requests per second, because percentages "meant nothing without load context."

Dropbox's backends. In 2014 Dropbox described migrating its performance-critical backends from Python to Go for concurrency support and execution speed: around 200,000 lines of Go, written by a small team that had to build the libraries the ecosystem lacked.

None of these is a controlled comparison. They show that the migrations happened and what each team traded, not that Go would produce the same result in your organization.

The language choice is an architecture decision

Uber's reasons (supported languages, protocol alignment, test volume, onboarding cost) are platform reasons. Choosing Go was a decision about which runtime the organization would staff, tool and support, for a defined part of the system and a defined period.

That makes it an architecture decision like any other: it has a scope, a lifecycle, an owner, and a reason someone will need in two years. Coding agents now make it cheaper to act on, because they can draft a Go translation of a Node.js handler quickly. Deciding which handlers move, in what order, through which interface and on what evidence costs as much as it always did. At Uber, the period after the decision was two years of migration.

Two implementations, one authority

Uber and Grab both describe a long period with both implementations in production. Grab ran Rails and Go side by side, one endpoint at a time. Uber's central team migrated a few high-scale APIs first as a pilot, then product teams migrated their own, with an automated tracker following progress. The sources do not catalogue failures, but our reading is that three are likely in a period like this, and none shows up as a broken build:

  • New work lands in the wrong stack. A feature goes into the Node.js service because the code nearby is Node.js, and one more endpoint now has to be migrated later.
  • A call crosses the approved interface. The new Go service reaches into the old service's internals or its database instead of going through the agreed contract.
  • An endpoint cuts over without its evidence. Traffic moves because the code exists, not because the comparison and latency checks the plan required have passed.

Each failure is a question of authority: which implementation is current for this path, and who may say it has changed. Grab's rollout answers part of that. The unit of cutover was the endpoint, not the service, so the authority to cut over attached to each endpoint. Its percentage lesson answers another part. The acceptance evidence has to be stated in units that reflect real load, and written down before rollout starts, or the rollout defines its own success.

ADRs for software migrations covers how architecture decision records (ADRs) carry legacy, target and transitional states, each limited to the paths it governs. Strangler-fig migrations covers moving traffic one boundary at a time behind a compatibility adapter, the translation layer between old and new, and keeping new code from reaching past it. A language migration adds one thing: the two sides are different runtimes, often in different directories or repositories, so the decision has to name each side's role.

Writing the migration decision down

An illustrative decision for a payment service moving from Node.js to Go might read:

All new payment-processing functionality is built in the Go implementation. Existing Node.js endpoints keep serving until each one cuts over. Calls between the two implementations use the approved payment interface only. An endpoint cuts over when its contract tests (tests that check both sides honour the agreed request and response formats) pass and its latency and error rates are validated against the Node.js endpoint it replaces.

Broken into the properties someone has to own, the same illustrative decision looks like this:

DimensionIllustrative example
ScopeNew Go packages and the payment service boundary
LifecycleNode.js transitional; Go target
PrecedenceThe approved migration decision overrides older guidance in its scope
AuthorityThe architecture owner approves the target; the service owner authorizes each cutover
EnforcementPath-scoped restrictions, plus language-specific linters and dependency checks outside Mneme
EvidenceContinuous integration (CI) results, compatibility tests, performance measurements

The precedence row is the easiest one to skip. The Node.js service almost certainly has older decisions about how payments are handled, and some conflict with the target. If the migration decision does not explicitly supersede them for its scope, an agent working in the payment code may find conflicting guidance and no record of which one wins. That is the question engineering decision governance exists to answer: which decision governs this change, why, and with what evidence. Precedence semantics covers the ordering when several decisions apply.

The authority row matters for a different reason. Approving Go as the target and approving one endpoint's cutover are separate acts by different people. Collapsing them into "the migration is approved" is how endpoints cut over on schedule rather than on evidence.

What can be enforced, and what cannot

Mneme, an open-source decision and control layer for agentic software development, can enforce part of this decision today. It enforces only decisions that carry an active typed rule: a machine-checkable constraint written into the decision, such as a forbidden literal, an exact string that must not appear in code. A rule can also list the paths it includes or excludes, so it can apply to the Go payment packages and stay silent in the Node.js tree. Forbidding the old service's internal hostname or database driver in the Go packages, with the approved interface client excluded, catches the second failure: the Go side reaching past the contract.

When an agent such as Claude Code changes a file through its own editing tools, the check runs before the file changes. Changes made through shell commands are checked when the agent finishes its turn, and CI checks every pull request. Each result is PASS, WARN or FAIL (a pass, a reported warning or a failure), and a violation names the decision it broke.

The decision lives in the Decision Index, Mneme's persisted, versioned record of decisions, which keeps each version, its lifecycle status and its source. That status is active, superseded, deprecated or inactive. "Transitional" and "target" are migration roles, not index states: you write them as separate decisions with their own paths, and you mark the transitional one superseded when the last endpoint moves. When an older decision should lose to the migration decision, name it in the new ADR's supersedes: field, the metadata at the top of the file, rather than relying on two accepted decisions to resolve themselves.

The first failure, new work landing in the Node.js service, is harder. "Build new payment features in Go" is not a string a rule can match; it is a question of where a feature belongs, and code ownership and review still answer it. The third failure is outside Mneme. Shadow comparison, contract tests, latency and error-rate validation, and the cutover itself belong to your test, deployment and observability tools. Mneme does not prove that two implementations behave the same, and it does not perform cutovers.

Mneme's supported languages reference lists TypeScript and JavaScript as Tier 1 (operationally supported), where repository-level enforcement works today, and Go as Tier 2, where the same repository-level rules apply but no Go-specific checks exist yet. In both, a typed rule matches exact literal text, optionally limited to certain paths. Neither has abstract syntax tree (AST) or compiler-aware enforcement, so anything that needs to understand Go types or Node.js module resolution comes from a language-specific tool.

Two more limits apply. The Decision Index records what each version said and where it came from, not who approved it, so keep the cutover approvals in pull request reviews or your deployment system. And whether governing a migration this way produces fewer decision violations without blocking legitimate transitional work is a claim nobody has tested yet.

What engineering leaders should do

  • Write the language choice as a decision, with its reasons. Uber's were platform support, protocol and test cost. Record yours, so the next team can tell whether they still hold.
  • Give each implementation a stated role and scope. Name which paths are transitional and which are target, and the condition that retires the transitional side.
  • Separate target approval from cutover authority. Name who approves each, and write the acceptance evidence per endpoint, in load-aware units, before rollout begins.
  • Enforce the interface mechanically; verify behavior elsewhere. Shadow testing and contract tests prove the behavior.

Where Mneme fits

Mneme applies to the part of a language migration that is a decision about code: which interface the target side may use, and which older decisions the migration supersedes. Its Architecture Audit scans a repository for the decisions it already documents, in ADRs, agent instructions and configuration, and shows which ones a typed rule enforces with the same verdict on every run. Migration-aware enforcement, migration assurance reporting and cross-language equivalence checks are research directions, not shipped capabilities.

Frequently asked questions

Why did Uber move its API gateway from Node.js to Go?+
Uber's 2020 Edge Gateway post lists platform and maintenance reasons: upgrading 2,500 npm libraries kept getting harder, 50,000 tests ran on every change, onboarding onto asynchronous Node.js patterns was slow, and the rest of the company had standardized on Go and Java while adopting gRPC. The migration took two years.
How do you migrate to Go without downtime?+
Grab's 2016 Rails to Go migration is a well-documented example. The Go server received shadowed production requests while the old server kept answering, a JSON tokenizer compared the two responses, endpoints moved one at a time, and any unforeseen bug meant an instant rollback. Percentage-based rollout misled when endpoint volumes differed, so Grab switched to requests per second.
What should a Node.js to Go migration decision record?+
Its scope, the role of each implementation (Node.js transitional, Go target), which older decisions it supersedes, who approves the target and who authorizes each endpoint's cutover, how it is enforced, and the evidence a cutover needs, such as contract tests and latency and error-rate measurements.
Can Mneme enforce a migration from Node.js to Go?+
Part of it. Mneme enforces decisions that carry an active typed rule, and a rule can be limited to the Go packages, for example to keep them from referencing the old service's internals past the approved interface. Its support for Go, JavaScript and TypeScript is repository-level. It does not check behavioral equivalence, validate performance or perform cutovers; those belong to test and deployment tools.