Architecture decision · Backend · October 2026
Go or Rust for a backend? How to choose when you love both
Go or Rust? Every time a new backend is on the table, the debate comes back, and it quickly turns into a match between two camps. I lived it from the inside, with a handicap: I love both. My project, a mobile app for iOS and Android, needed a server that is cheap to run, secure, fast, and easy on phone batteries. How do you decide when both languages tick almost every box? Here is how I decided, what tipped the scales, and what I agreed to give up.
- devs
- CTOs
- Status
- Accepted
- Context
- Backend of a native mobile app
- Options
- Go, Rust
- Deciding factors
- The protocol, tomorrow’s team, operations
- Reversible?
- Yes, service by service
I love Go. I love Rust. I’m saying it up front, because this debate usually plays out between two camps that don’t listen to each other: those for whom Rust is the only serious answer to everything, and those for whom it’s a complication nobody needs. I’m in neither. I’ve written code in both, and each taught me something the other wouldn’t have.
So when I had to choose the language for my project’s backend, a native mobile app, I genuinely hesitated. Not between a good language and a bad one: between two excellent tools that made different bets. This article tells the story of that hesitation, and how I got out of it.
The first question has no answer. Or rather, it has a thousand, and they all contradict each other. The second has one, but it only holds for one project. That’s why I’ll start with the project, not the languages.
I wrote this article the way a team records an architecture decision: an ADR, for Architecture Decision Record. The format, popularized by Michael Nygard, is disarmingly simple: the context, the options, the decision, its consequences. It forces you to write down why, not just what. And it leaves a trail for whoever opens the repository two years from now wondering “why on earth did they pick Go?”. That might be me.
The project, and what it demands
A language isn’t chosen in the abstract. It’s chosen against constraints. Here is the project, and the four constraints that did all the work.
A mobile app, and its three audiences
The project is a native mobile app for iOS and Android. Its backend does what most backends do: authenticate, store, sync, notify. Nothing exotic, and that’s exactly what makes the choice interesting: neither Go nor Rust is ruled out from the start. This backend serves three audiences:
The app is by far the biggest consumer, and it talks over a mobile network that comes and goes. Keep this diagram in mind: it comes back in § 2.
Four constraints, each pulling its own way
Infrastructure costs
Every megabyte of memory and every millisecond of CPU is paid for, every month, forever. I want small instances, few of them, that start fast. Above all, few moving parts: every extra component means another bill, more monitoring, another on-call duty.
What it asks of the backend: a modest memory footprint, fast startup, a single binary, a single port.
Security
Personal data, an API exposed to the Internet, accounts to protect. I want to eliminate as many classes of bugs as possible at write time, keep a dependency list I can still audit, and ship a security fix in an hour, not a week.
What it asks of the backend: a memory-safe language, a small dependency surface, good vulnerability-scanning tools.
Battery
The most underestimated constraint, and the most misunderstood. The backend doesn’t drain the phone’s battery: it makes the phone drain it. Through the size of what it sends, the number of round trips it forces, and how long it keeps the radio awake.
What it asks of the backend: compact messages, few round trips, a reused connection, pushing rather than being polled.
Performance
The latency users feel, especially on mobile networks, and server-side throughput. With a nuance experience has taught me: on this kind of backend, the bottleneck is almost always the database or the network, rarely the language.
What it asks of the backend: steady latency (not just a good average), and a protocol that doesn’t waste round trips.
Battery life is decided by the contract, not the language
Let’s pause on battery, because that’s where the most counterintuitive argument of this article hides. After each exchange, the cellular radio, one of the phone’s most power-hungry components, doesn’t go back to sleep right away: it stays awake for several seconds in case more data arrives (researchers call this the “tail”). Ten small, spaced-out requests therefore cost far more than a single one, even for the same volume:
Reread what battery asks of the backend: compact messages, few round trips, a reused connection, pushing rather than being polled. The backend’s language appears nowhere. What appears is the protocol. That’s why I chose the protocol before the language, and why that first choice weighed heavily on the second.
What the project is not
In an ADR, what you rule out matters as much as what you keep. Three “no”s weighed heavily:
If any of those three “no”s had been a “yes”, this article would probably have a different conclusion. I’ll show you which one in § 4, sliders included.
§ 1 · contextstatus: established
The backend’s language has almost no effect on the phone’s battery. The protocol does. So the contract had to be chosen before the language.
§ 2 · The contractREST, gRPC, or both?
Before choosing the language, I had to choose how the app would talk to its server. At the time, I didn’t even think about it: I picked REST, as always. It was when I held it up against the constraints of § 1 that I started to have doubts.
The reflex: REST
I’ve used REST in almost every project, and that’s probably true for you too. The refresher fits in one picture:
Simple, universal, understood by every tool, from the browser to curl. But when I reread what battery asks of the backend, not a single box was ticked by default:
- Compact messages? JSON repeats every field name, in every message.
- A contract shared by the server and both apps? OpenAPI exists, but it’s optional, and often written after the fact.
- A server that tells you when something changes? By default, the app has to ask.
Nothing insurmountable: you can compress, generate clients, add a WebSocket. But each time, it’s one more brick to lay yourself, on both sides. So I wondered whether some protocol did all of this by design.
What I found next door
Two names kept coming up.
GraphQL
born at FacebookA single endpoint, and the client picks exactly the fields it wants. A real asset when very different screens read the same data. But messages are still JSON.
gRPC
born at GoogleYou no longer call an address, but a server function, described in a contract. I knew it by reputation, for getting microservices to talk to each other. Not for a mobile app.
GraphQL mostly solved a problem of data shape, which I didn’t have. gRPC, I barely knew: I went to take a closer look.
gRPC in two minutes
gRPC comes from RPC, for Remote Procedure Call: instead of building an HTTP request, the app calls a function, and the server runs it. Everything starts from a contract, a .proto file, which describes the data and the functions in the same place:
proto/app/sync/v1/sync.proto (simplified excerpt)syntax = "proto3"; package app.sync.v1; // One file describes the whole contract: the data AND the calls. // ── 1. The messages: the data exchanged, field by field ── message SyncRequest { string device_id = 1; // each field has a type… int64 since_version = 2; // …and a number: the number travels, not the name repeated Change changes = 3; // a list of changes } message SyncResponse { int64 version = 1; repeated Change changes = 2; } // (Change, WatchRequest and Event are declared the same way.) // ── 2. The service: the functions the app can call ── service SyncService { // Send local changes and receive the server’s, in a single round trip. rpc Sync(SyncRequest) returns (SyncResponse); // While the app is in the foreground, the server pushes what changes. rpc Watch(WatchRequest) returns (stream Event); }
From this single file, generators produce the code on each side:
At runtime, the app calls the function as if it were local. The generated code handles the network: a compact binary format, Protocol Buffers, over an HTTP/2 connection opened once.
sync like an ordinary function. The network is hidden behind the generated code.What convinced me
It wasn’t the speed people attribute to gRPC that stopped me. It was the contract. A renamed field breaks the build, not production. And Protocol Buffers’ compatibility rules (you add fields, you never reuse a number) let the API evolve without breaking older versions of the app, which will stay installed for months on some phones.
It still had to face the constraints of § 1. First test, message size:
The rest fits in a table. On almost every row, gRPC does by design what REST asks you to add:
| For the mobile app | REST | GraphQL | gRPC |
|---|---|---|---|
| Compact messages Battery | JSON, text | JSON, only the requested fields | Compact binary |
| One connection for every call BatteryPerformance | Possible, over HTTP/2 | Possible, over HTTP/2 | Always: HTTP/2 is its transport |
| The server pushing Battery | To be added (SSE, WebSocket) | Subscriptions, often over WebSocket | Streams built into the protocol |
| A contract that generates client code Security | Optional (OpenAPI) | Yes (typed schema) | Yes, it’s at its core |
| A deadline and cancellation that travel with the call Cost | Build it yourself | Build it yourself | Built into the protocol |
| In a browser | Native | Native | Via gRPC-Web or Connect |
| Reading and testing by hand | curl, everywhere | Good dedicated tools | Dedicated tools (grpcurl) |
GraphQL kept one asset, the client’s freedom. But my problem was battery, and on that ground gRPC started several lengths ahead.
What gRPC costs
I’m wary of articles that present gRPC as a no-brainer. So I looked at the other side of the coin:
curl is no longer enough. You need grpcurl, reflection enabled on the server, or a GUI client that understands gRPC.The real problem: browsers
That left the admin panel. It lives in a browser, and a browser doesn’t give JavaScript the control over HTTP/2 that gRPC needs: you have to go through gRPC-Web or the Connect protocol, with a proxy or a compatible server. Doable, but I wanted the right tool in each place, not one tool bent to fit everywhere.
And in a browser, the right tool is REST: readable JSON, curl to test, OpenAPI to document. Same conclusion for third-party services, which send their webhooks as JSON and will never speak our protobuf.
gRPC first, REST where it’s needed
That’s the answer I kept. Let’s go back to the diagram from § 1, with both protocols:
The app speaks gRPC: that’s almost all the traffic. REST keeps what speaks HTTP by nature: the browser, webhooks, and a few needs of the phones themselves, such as universal-link files or large file uploads.
gRPC, for the app
On the app side, the contract fits in the two calls of the .proto: Sync bundles everything into one round trip, Watch lets the server push. Their rhythm follows the app’s:
- In the foreground
Watchis openThe server pushes what changes. No more “anything new?” every thirty seconds.
- In the backgroundThe system suspends the app
The stream eventually closes. If something important comes up, a push notification (APNs, FCM) takes over.
- Back in front
Sync, thenWatchA single round trip catches up on what was missed, and the stream resumes.
Why this mix weighs on the choice of language
This is where the protocol catches up with the language:
- gRPC is the core, so its ecosystem becomes criterion number one. Both languages have a gRPC library. The real question: is it official, complete, and will it still be the same in three years?
- REST is secondary and ordinary, so it barely separates them. Both languages serve JSON very well. It’s a criterion you check, not one that decides.
- One port, so the two must be married cleanly. One service to deploy, one certificate, one health check, one line on the bill: that’s the cost constraint talking. You need to serve gRPC and REST on the same address, without an extra proxy.
§ 2 · contractstatus: gRPC primary, REST secondary, one port
The contract is set. It turns a question of taste (“which language do I prefer?”) into a question of ecosystem (“which one best serves this contract?”). Over to the two candidates.
§ 3 · The optionsGo and Rust, seen from the workshop
I’m not going to redo the comparison you find everywhere, with its benchmark tables and feature lists. Here’s what I look at, personally, when I compare two languages for a service that has to live a long time, and why.
What I don’t compare
“Hello world” benchmarks
They measure a framework returning a string. My service waits on a database. The winner of that kind of benchmark tells you nothing about mine.
Syntax
You get used to any syntax in a few weeks. What remains are the ideas the language imposes on you, or spares you.
Popularity on social media
A community’s enthusiasm is an excellent signal for learning a language. It’s a poor signal for choosing one for a product.
What the big company next door does
It has neither my team, nor my budget, nor my constraints. Its reasons are not mine.
What I compare
The concurrency model
A backend spends its time waiting on thousands of things at once.
How does the language express it, and at what cost to whoever reads the code?
Memory and latency
Garbage collector or not? It’s not a question of average speed.
Is latency steady, and what instance size will I have to pay for?
Errors and absence
That’s where most production incidents are born.
How does the language handle what fails, and what doesn’t exist?
The feedback loop
Compile, test, restart: it shapes everything else.
How long between “I change it” and “I know whether it works”?
The cost of reading
Code is read far more often than it’s written, and more and more, we reread code an AI wrote.
Can a newcomer reread this code and judge whether it’s right?
Life in production
Build, deploy, profile, upgrade.
Will today’s code still compile in five years?
Go, the bet on boring
A language that deliberately chose to do less, so that whole teams could do more.
Go was designed at Google with a goal Rob Pike summed up very well: to serve software engineering at scale, with large teams, huge codebases and developers who come and go. Hence its often-mocked choices: few keywords, a single way to format code (gofmt), no inheritance, no magic. The result is a boring language, in the best sense of the word: one project’s Go code looks like another project’s Go code. You open an unfamiliar repository, and you read it.
For concurrency, Go has goroutines: very lightweight threads of execution, managed by the runtime, that you launch by the thousands without a second thought. The code stays sequential to read; the runtime handles scheduling. For a backend waiting on the database, the network and third-party services, it’s exactly the right model. For memory, a concurrent garbage collector, designed for short pauses. It keeps improving: since Go 1.26, a new collector (Green Tea) is enabled by default, and the release notes announce a significant reduction in time spent collecting for allocation-heavy programs.
What I love
- Near-instant compilation, and a single static binary to deploy.
- A huge, solid standard library: HTTP server, TLS, cryptography, testing, profiling (
pprof). - The Go 1 compatibility promise: code written ten years ago still compiles.
- Tooling shipped with the language: formatting, testing, data race detector, fuzzing, vulnerability scanning.
- Code any backend developer can read in their first week.
What it costs me
nil, and its twisted cousin: the interface holding a nil pointer that is nevertheless notnil.- The
if err != nilrepeated on every other line. Explicit, yes. Verbose, too. - A modest type system: generics since Go 1.18, but no real sum types, no enums worthy of the name.
- Data races remain possible: you catch them by testing, the compiler doesn’t forbid them.
Rust, the compiler that reviews with you
A language that made the opposite bet: tell the compiler everything, so it can check everything.
Rust has no garbage collector. Memory is managed by a system of ownership and borrowing that the compiler checks: every value has an owner, and you can lend it for reading to many or for writing to one, never both at once. This rule, so painful at first, has a remarkable consequence: unsynchronized concurrent memory accesses (the infamous data races) become compile errors.
What attracts me most, though, isn’t performance. It’s modeling. Enums that carry data, a match that refuses to compile if you forget a case, Option instead of nil, Result and the ? operator for errors. You describe the state of an order, a payment or a session with a precision Go doesn’t allow, and the compiler keeps that precision for you. When a Rust program compiles, a whole category of questions disappears from code review.
And the performance is real. Without a garbage collector, latency is steady and the memory footprint small. The most cited story is Discord’s, which in 2020 rewrote in Rust a Go service whose garbage collector caused regular latency spikes. Go’s garbage collector has improved a lot since, but the principle holds: what you don’t collect costs nothing. Cloudflare, for its part, replaced part of its proxy infrastructure with Pingora, written in Rust.
What I love
- No
null, no data race, no error ignored by accident: the compiler sees to it. - Types that describe the business, and impossible cases that become impossible to write.
- Steady latency and a very small memory footprint, hence smaller instances.
cargo, which remains in my eyes one of the best package managers there is.- That rare feeling: “if it compiles, it works”, almost always true.
What it costs me
- The learning curve: borrowing and lifetimes demand a real investment.
- Async, the hardest part of the language: choice of runtime,
Sendconstraints,Pin, intimidating error messages. - Long compile times, which stretch the feedback loop.
- A wide dependency tree: even the async runtime is an external library.
Same code, two philosophies
Nothing beats an example. Here’s the same business function in both languages: fetch a user’s profile, refusing deleted accounts.
func (s *Service) Profile(ctx context.Context, id string) (*Profile, error) { u, err := s.users.Get(ctx, id) if err != nil { return nil, fmt.Errorf("profile %s: %w", id, err) } if u.Status == StatusDeleted { return nil, ErrNotFound } // A new status added tomorrow? // Nothing will force me back here. return toProfile(u), nil }
async fn profile(&self, id: UserId) -> Result<Profile, AppError> { let user = self.users.get(id).await?; // the error propagates match user.status { Status::Active => Ok(Profile::from(user)), Status::Deleted => Err(AppError::NotFound), // A new status added tomorrow? // The code won’t compile until // it’s handled here. } }
The Go code is longer, and everything is in plain sight: every error, every return. A developer who has never written Go understands it by reading it. The Rust code is denser and safer: the error is typed, forgetting a case is impossible. But part of the work is elsewhere (converting errors into AppError, defined once and for all), and you have to know the language to see it.
Neither is “better”. Go optimizes for the person who reads the code without knowing it. Rust optimizes for the person who changes it six months later without remembering everything. Both are right; the question is which of these two people your project most needs to protect. And for me, a recent criterion tipped the answer.
Code you didn’t write yourself
Today, and even more tomorrow, a good share of code will be written by AI. Developers write less and review more: that’s the thesis of my first article, “Will AI replace developers? Wrong question.” In that world, easy-to-read code is no longer a comfort: it’s a selection criterion, and it weighed on mine.
For someone who knows Rust well, the question barely arises: they review generated code without trouble, and the compiler catches many of the AI’s mistakes along the way. But think of the developer who joins the team and codes with an assistant. In Go, what the AI produces reads like the rest of the repository, and they can judge whether it’s right. In Rust, lifetimes, traits and async demand a solid knowledge of the language to judge code you didn’t write. Accepting code you don’t understand just because it compiles is exactly what I want to avoid.
§ 3 · optionsstatus: two serious candidates
On paper, I could have defended either one before any architecture committee. So I had to get off the paper, and confront each ecosystem with the project’s real constraints.
§ 4 · The criteriaThe ecosystems against my constraints
You never write a backend with a language alone. You write it with a compiler, libraries, tools, habits and people. That’s the whole package I compared, criterion by criterion, with one simple question each time: which one best serves this project?
gRPC, criterion number one
Go’s edgegrpc-go has been one of gRPC’s official implementations from the start, maintained by the gRPC project itself. Everything is there: interceptors, standard health service, reflection, keepalive, client-side load balancing, xDS. Around it, a complete ecosystem: go-grpc-middleware (authentication, logging, panic recovery, rate limiting), OpenTelemetry instrumentation, grpc-gateway to generate REST from the contract, ConnectRPC, and protovalidate validation.tonic, built on hyper, tower and tokio, with prost for Protocol Buffers, is a very good library, proven in production, with its health, reflection and gRPC-Web modules. But the ecosystem is in the middle of a transition:- Until now
tonicThe reference library, driven by the community.
- May 2026tonic joins the gRPC project
And moves to maintenance mode: fixes only.
- ComingAn official
grpccrateWith connection management and client-side load balancing.
Excellent news for Rust, in the medium term. But for a project that’s starting, it’s a transition to get through: the library you’d pick today isn’t the one you’ll be using in two years. On the Go side, the question doesn’t arise.
REST and HTTP
Tienet/http is one of the best HTTP servers in any standard library, and since Go 1.22 its router handles methods and path parameters. On top: Chi, Echo or Gin. I avoid frameworks that leave net/http, like Fiber, which relies on a different HTTP engine.axum, developed by the tokio team, is remarkable: typed, composable, no invasive macros. actix-web is very fast and very mature. Nothing to complain about.As anticipated in § 2: a criterion you check, not one that decides.
One port for gRPC and REST
Tiecmux, run gRPC through the standard HTTP server (ServeHTTP, still experimental), or adopt ConnectRPC, which serves gRPC, gRPC-Web and Connect with plain net/http handlers. More on this in § 6.hyper and tower). Composing them on the same port is almost natural: they’re just two tower services to route.I gladly admit it: on this precise point, the Rust solution is even more elegant. That doesn’t make it simpler to operate.
Security
A draw, for opposite reasonsunsafe and data races). Cryptography and TLS are in the standard library, maintained by the Go team, with a FIPS 140-3 certifiable module since Go 1.24. govulncheck only reports vulnerabilities in code you actually call, which keeps useful alerts from drowning. And few dependencies, so little to audit.rustls for TLS, cargo audit (RustSec database), cargo deny and cargo vet for the supply chain. But a tonic service pulls in a noticeably wider dependency tree, and each dependency is a potential entry point.Rust wins on language guarantees, Go on attack surface and tooling simplicity. For an API backend, where vulnerabilities come far more often from a logic error (a forgotten authorization check) than from memory, I call it a draw.
Cost and performance
Rust’s edgeGOMEMLIMIT lets you tell the garbage collector how much memory the container can use, and the new collector cuts its cost further. Near-instant startup.The gap is real, but it melts away when the service spends its time waiting on a database. And in a project that’s starting, the most expensive resource isn’t CPU: it’s developer time. Rust wins on the infrastructure bill; on total cost, it’s much less clear-cut.
Operations
Go’s edgeGOOS, GOARCH), static binary, tiny container image. Profiling is built into the language: plug in pprof and see where CPU and memory go, in production. And cloud tools (Kubernetes, Docker, Prometheus, etcd…) are written in Go: their client libraries are the most complete.cross). Profiling goes through system tools, very good but less integrated.Both are easy enough to operate. Go simply requires less knowledge to do it.
Hiring, onboarding, handing over
Go’s edgeThe usage gap is smaller than people say, and the “six months to learn Rust” legend is exaggerated. But my problem isn’t hiring an expert: it’s making any good backend developer productive on the codebase, quickly, without needing a Rust mentor at their side. On that point, Go wins clearly.
The summary, on one page
| Criterion | Go | Rust | For this project |
|---|---|---|---|
| gRPC ecosystem | Official, complete, stable | Very good, moving to an official library | Go |
| REST | net/http, Chi | axum, actix-web | Tie |
| One port | cmux, ServeHTTP, Connect | tonic + axum on tower | Tie |
| Security | Small surface, good tooling | Compiler guarantees | Tie |
| Memory, latency | Garbage collector, well tuned | No collection, very frugal | Rust |
| Feedback loop | Near-instant compilation | Long compilation | Go |
| Operations | Simple, built-in profiling | Good, more demanding | Go |
| Tomorrow’s team | Quickly productive | Longer to train | Go |
| Domain modeling | Modest types | Enums, Option, Result | Rust |
Seen this way, Go seems to win by a wide margin. That’s misleading: a table counts rows, it doesn’t weigh them. And that’s where the whole debate lies. If latency and modeling matter twice as much as the rest for you, the table no longer means anything. Hence the tool below.
The weighing
My scores (out of 5) for each language, for this kind of backend. They’re subjective, and I own them. What matters is the mechanism: change the weight of each criterion according to your project, and watch the winner change.
Score = sum of the weighted scores, scaled to 100. Presets only change the weights, not the scores; in reality, a team already expert in Rust would also change the hiring and iteration scores.
With my weights, Go wins by about half a dozen points: a real lead, not a rout. Now check the box: a single criterion moving by one point, and the gap is halved. That, in one checkbox, is why the decision wasn’t easy. It’s also why I’ll revisit it.
§ 4 · criteriastatus: weighed
The scales tip, but not far enough to make the choice obvious. I had to decide, and own what the decision costs.
§ 5 · The decisionGo, without regret, but not without hesitation
The backend is written in Go. gRPC for the app, REST for the admin panel and a few mobile endpoints, all served by a single binary, on a single port.
Made knowingly, with what it costs. To be revisited if one of the conditions described below changes.
Why Go
- The best gRPC support, today
That’s the reason that tipped the scales. gRPC is the heart of the backend, and
grpc-gohas been official, complete and stable for years, with the whole ecosystem around it. On the Rust side, the quality is there, but the reference library is changing hands. I didn’t want to build the core of the project on a transition. - Hiring, onboarding, handing over
The team will grow. With Go, a good backend developer, whatever their original language, will be useful very quickly, and the codebase will stay readable by everyone, including whatever their AI assistant adds to it. A project doesn’t only live with those who started it: it lives with those who take it over.
- A short feedback loop
Compile, test, restart: in Go, it’s near-instant. Early in a product, you change your mind every day, and how fast you can be wrong and then fix it matters more than the speed of the binary.
- A reduced attack surface
TLS, cryptography, HTTP server, testing: the standard library covers the essentials. Fewer dependencies means less to audit and update, and fewer doors for a supply-chain attack.
govulncheckdoes the rest. - Operations without surprises
A static binary, a tiny image, cross-compilation with one environment variable, built-in profiling, and a compatibility promise that guarantees upgrades won’t turn into a project of their own. For a small team, that means less time on call.
- Fast enough, and getting faster
The service mostly waits on the database. For this profile, Go is more than fast enough, and its garbage collector improves with every release. “Fast enough” isn’t an excuse: it’s a criterion, provided you’ve checked it against your own load rather than a benchmark.
What I sacrifice, and how I compensate
I won’t pretend: by choosing Go, I give up things I really love in Rust. But a sacrifice you own can be compensated. Here, for each one, is what I put in place.
go test -race in CI, on the whole test suite, and load tests that enable it too.staticcheck and golangci-lint, Uber’s nilaway analysis against nil pointers, and explicitly written state machines, with one test per transition.GOMEMLIMIT set to the container size, pprof in production, and allocations watched on the busiest paths.go test -fuzz) on everything that decodes external input, and contract tests on protobuf messages.What would change my mind
A decision you can’t question isn’t a decision, it’s dogma. Here are the three events that would make me reopen this ADR:
- A service that really computes. If one day the critical path contains heavy processing (media, intensive cryptography, a real-time rules engine), that service will be written in Rust.
- A team that already speaks Rust. With two or three experienced Rust developers on the team, the hiring criterion collapses, and the weighing changes sides.
- A mature official gRPC crate in Rust. That’s the checkbox in the weighing. The day it’s stable and complete, the criterion that tipped everything becomes a tie.
The best argument for gRPC: it makes the choice of language reversible, service by service.
The contract lives in the .proto files, not in the Go code. Tomorrow, a Rust service can join the backend behind the same contract, and the app won’t see the difference. Choosing gRPC makes choosing Go far less final.
.proto contracta Go service, todaya Rust service, tomorrow?
There is no best language
I’ll say it plainly, because it’s the heart of this article: there is no best language. There are languages that make different bets. Go bets that simplicity scales with teams. Rust bets that rigor scales with systems. Both are right, and history has proven them right. The right question is never “which one is best?”, but “which of these two bets does my project need to win?”.
Had I been building a network proxy, a database, a game engine, embedded software or a WebAssembly module, I would have chosen Rust, without even opening an ADR. And, a small irony: if one day I want to share logic between the iOS and Android apps, inside the phone, it’s Rust I’ll turn to again, with tools like Mozilla’s UniFFI, which generate Swift and Kotlin bindings.
| If your project is… | I’d lean toward | Because |
|---|---|---|
| An API backend, microservices, a growing team | Go | Readability, onboarding speed, cloud and gRPC ecosystem. |
| An MVP to ship fast, with a generalist team | Go | The feedback loop and the learning curve come first. |
| A command-line tool, an infrastructure tool | Both | Two excellent choices; the team decides. |
| A proxy, an engine, a latency-critical service | Rust | No collection, steady latency, every cycle counts. |
| Embedded, systems, WebAssembly | Rust | No heavy runtime, fine-grained memory control. |
| A library shared between iOS and Android | Rust | One core, generated bindings for Swift and Kotlin. |
§ 5 · decisionstatus: accepted
The decision is made. What remains is the most concrete part: how gRPC, REST and Chi fit together behind a single port, and the pitfalls to know.
§ 6 · ImplementationThe chosen stack
An architecture decision is only worth its implementation. Here’s how the pieces fit together, with the code that matters, and the pitfalls to know before diving in.
Overview: one port, two servers, one logic
The contract first
Everything starts from the .proto files, kept in a separate folder and versioned with the rest. I use Buf rather than protoc directly: a readable configuration to generate the Go code (and the apps’ code), a linter that enforces naming conventions, and above all breaking-change detection. That’s what protects older versions of the app installed on phones.
buf lint # contract conventions buf breaking --against '.git#branch=main' # no breaking change against main buf generate # Go code regenerated; the diff must be empty
Chi, for REST
For REST, I picked Chi, because it stays 100% net/http: its handlers and middlewares are the standard library’s, and it only adds what’s missing (route groups, sub-routers, proven middlewares), with no dependencies. Why Chi rather than Gin, Echo or Fiber? The comparison deserves an article of its own, and I’ll write one.
internal/rest/router.go (excerpt)func NewRouter(svc *service.Service, auth *Auth) http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID, middleware.Recoverer, middleware.Timeout(15*time.Second)) r.Get("/healthz", healthz) // the load balancer r.With(verifySignature).Post("/webhooks/{provider}", webhook(svc)) // third-party services r.With(auth.Mobile).Post("/mobile/v1/uploads", createUpload(svc)) // a presigned URL r.Route("/admin/api", func(r chi.Router) { // the admin panel r.Use(auth.Admin, httprate.LimitByIP(120, time.Minute)) r.Get("/users", listUsers(svc)) r.Post("/users/{id}/suspend", suspendUser(svc)) }) return r }
One port: cmux
That leaves the piece that holds it all together, the diamond in the diagram above. cmux reads the start of each connection without consuming it, and hands it intact to the right server: gRPC if it carries content-type: application/grpc, HTTP otherwise. I chose it for a precise reason: it lets grpc-go use its own HTTP/2 transport, optimized for gRPC, whereas solutions that go through the standard HTTP server switch transports.
cmd/server/main.go (imports omitted)func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() svc := service.New(/* data access, configuration… */) auth := rest.NewAuth(/* keys, sessions… */) lis, err := net.Listen("tcp", ":8080") if err != nil { slog.Error("cannot listen", "err", err) os.Exit(1) } // One port, two servers: cmux reads the start of each connection and routes it. m := cmux.New(lis) grpcL := m.MatchWithWriters( cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"), ) httpL := m.Match(cmux.Any()) // everything else: REST grpcSrv := grpc.NewServer( grpc.ChainUnaryInterceptor(grpcapi.Recover, grpcapi.Auth, grpcapi.Log), grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 5 * time.Minute}), ) grpcapi.Register(grpcSrv, svc) healthpb.RegisterHealthServer(grpcSrv, health.NewServer()) var protos http.Protocols protos.SetHTTP1(true) protos.SetUnencryptedHTTP2(true) // Go 1.24+: HTTP/2 without TLS, behind the load balancer httpSrv := &http.Server{ Handler: rest.NewRouter(svc, auth), Protocols: &protos, ReadHeaderTimeout: 5 * time.Second, } g, ctx := errgroup.WithContext(ctx) g.Go(func() error { return grpcSrv.Serve(grpcL) }) g.Go(func() error { return ignoreClosed(httpSrv.Serve(httpL)) }) g.Go(func() error { return ignoreClosed(m.Serve()) }) g.Go(func() error { <-ctx.Done() // shutdown signal received, or one of the servers failed grpcSrv.GracefulStop() sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() err := httpSrv.Shutdown(sctx) m.Close() return err }) if err := g.Wait(); err != nil { slog.Error("stopped on error", "err", err) os.Exit(1) } } // A server stopped on purpose is not an error. func ignoreClosed(err error) error { if errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) || errors.Is(err, cmux.ErrListenerClosed) { return nil } return err }
Pitfalls to know
cmux decides once, when the connection opens. If a client sends REST and gRPC over the same HTTP/2 connection, the second family of requests will reach the wrong server. In practice, the gRPC library and the app’s HTTP stack each open their own connections; but that’s something to check, not assume.
Some gRPC clients (Java’s, notably) wait for the server to speak first in HTTP/2. Hence MatchWithWriters and HTTP2MatchHeaderFieldSendSettings rather than the simple rule: cmux sends that first message itself.
Behind cmux, http.Request.TLS stays empty, even if the connection was encrypted. I terminate TLS at the load balancer, and the backend only sees internal traffic. If TLS must reach the binary, terminate it before cmux, and stop relying on that field.
GracefulStop waits for all in-flight calls to finish, including Watch streams, which can last for hours. You need a limit: beyond a few seconds, call Stop, and the app reconnects on its own.
The load balancer polls /healthz over HTTP; gRPC tools use the standard health service (grpc.health.v1). Both must say the same thing, or you end up sending gRPC traffic to an instance that HTTP believes is healthy.
The server policy (MinTime) and the mobile clients’ configuration must be tuned together. A client that’s too chatty gets its connection cut; a server that’s too strict cuts off good clients.
Alternatives I ruled out (for now)
grpcServer.ServeHTTPThe official method: a root HTTP handler that sends HTTP/2 requests carrying application/grpc to gRPC, and the rest to Chi. Simple and elegant, but marked experimental, and it goes through net/http’s HTTP/2 implementation, with, according to its own documentation, different performance and features.net/http handlers, mounted in Chi, and they speak gRPC, gRPC-Web and the Connect protocol (readable with curl). The admin panel could even call the same methods as the app. If I started from scratch tomorrow, I’d seriously evaluate it.content-type takes a few lines of configuration. But it’s one more component to deploy, monitor and update: exactly what the cost constraint forbids.The tree and the image
backend/ ├── proto/ # the contract: .proto, buf.yaml, buf.gen.yaml ├── gen/ # generated code, never edited by hand ├── cmd/server/ # main.go: one binary, one port ├── internal/ │ ├── service/ # business logic, no gRPC or HTTP │ ├── grpcapi/ # gRPC → service adapters │ ├── rest/ # Chi: admin, webhooks, mobile │ └── store/ # data access └── Dockerfile
DockerfileFROM golang:1.26 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -trimpath \ -ldflags="-s -w" -o /server ./cmd/server FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /server /server EXPOSE 8080 ENTRYPOINT ["/server"]
The golden rule of this tree fits in one sentence: internal/service knows neither gRPC nor HTTP. Adapters translate protobuf messages and JSON requests into service calls, and service errors into gRPC or HTTP codes. That’s what lets you add a door, or close one, without touching the business logic. And it’s also what would make it possible, one day, to rewrite a service in Rust behind the same contract.
§ 6 · implementationstatus: in place
The stack fits in one binary, one port and a handful of libraries. One last ADR section remains, the one everyone forgets: the consequences.
§ 7 · ConsequencesWhat we gain, what we pay, what we watch
An honest ADR ends with its consequences, good and bad. It’s the section whoever wants to undo this decision one day will reread. Might as well make their job easier.
What we gain
- A single contract between the server and both apps, checked on every pull request.
- One binary, one port, one image: minimal infrastructure.
- A codebase any new backend hire can read very quickly.
- A stable gRPC ecosystem under the core of the product.
What we pay
- Guarantees to build with tests and linters, where Rust would have provided them.
- A higher memory footprint than Rust, and less steady latency under heavy load.
- A dependency on cmux, a small library, at the heart of server startup.
- Debugging binary gRPC, which needs its own tools.
What we watch
- The maturity of Rust’s official gRPC crate.
- 99th-percentile latency and time spent in the garbage collector, measured in production.
- Heavy processing appearing on the critical path.
- ConnectRPC, as a possible replacement for cmux.
Your turn
I don’t claim to have the only right answer, just mine, for this project. Here are the questions I’m still asking myself. I’d love to read yours.
Have you ever regretted a language choice? Was it the language, or what you expected of it?
Does your team write ADRs? Who rereads them two years later?
If AI writes more and more code, does Go’s readability become a decisive advantage, or the Rust compiler’s rigor an indispensable safety net?
gRPC on mobile: have you adopted it, or is JSON still king for you? Why?
With your own weights in the weighing, who wins?
And you, which language do you love… without ever choosing it for your projects?
To reply, write to me by email or on LinkedIn. I’m always happy to reply, as my schedule allows, sometimes a little late, but I always reply.
The best language doesn’t exist. There is the right language for a project, a team and a moment. I love Rust. This project needed Go.
Developer, October 2026
PS: the day one of this backend’s services truly needs Rust, I’ll write ADR-002. And I’ll be happy to tell you all about it.