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.

Written for
  • 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 question everyone asks What is the best language for a backend?
The question I asked myself For this project, these constraints and the team that will maintain it in three years, which language makes the fewest compromises on what really matters?

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.

This article describes both ecosystems as they stand at the end of October 2026. The broad strokes will hold for a long time; the libraries move fast, especially gRPC on the Rust side, which is going through a major transition (more on that in § 4). I’ll update the article when the essentials change.
§ 1 · context

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 backend’s three audiences. On the left, the app, on iOS and Android phones: almost all the traffic. On the right, the admin panel, opened by the team in a browser, and third-party services, which send webhooks. All of them talk to the same backend, in the middle, which is still to be written, in Go or in Rust. almost all the traffic The app iOS Android The backend the server to write in Go or in Rust? Admin panel the team, in a browser Third-party services webhooks, events
Three audiences, one backend. What remains is which language to write it in, and which protocol each audience uses to talk to it.

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:

Diagram of a phone radio’s activity. At the top, an app that polls the server every thirty seconds: each request wakes the radio, which then stays awake for its tail period; it almost never goes back to sleep. At the bottom, an app that keeps a stream open: the radio wakes when the stream opens, then only once when the server sends a batch of events, and sleeps the rest of the time. Polling every 30 s the radio almost never sleeps An open stream the server only speaks when it has something to say opening a batch of events the radio sleeps data transfer tail: radio still awake, nothing transmitted radio idle
Not to scale. What costs energy isn’t only the volume transmitted, it’s the number of times you wake the radio. The tail effect is described notably by Huang et al. (MobiSys 2012) for 4G.

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:

Not a compute engine. The critical path is mostly I/O: database, cache, third-party services. The server spends most of its time waiting.
No hard real-time. Steady latency, yes. Microsecond guarantees, no. Nobody is in danger if a response arrives a few milliseconds late.
Not a team of experts waiting. The team will grow, and the people who will join it haven’t been hired yet. The language has to welcome them.

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 contract
§ 2 · contract

REST, 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:

A REST exchange. The client sends the request GET /users/42: an HTTP verb and a resource address. The server replies 200 OK with JSON, which repeats every field name. The client app, browser, curl… The server one address per resource GET /users/42 the request: an HTTP verb and a resource address 200 OK {"id": 42, "name": "Awa", "premium": true} the response: readable JSON, repeating every field name
REST in one picture: an address, a verb, some JSON. Everything is readable, including every field name, repeated in every response.

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:

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 Facebook

A 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 Google

You 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:

One contract, the same code for everyone. The sync.proto file goes through generators, which produce three pieces of code: the server, where the functions remain to be implemented, and the Swift client for the iOS app and the Kotlin client for the Android app, ready to call. All three share the same names: SyncRequest, SyncResponse, Sync and Watch. sync.proto the contract, written once messages and service generators the same names, in all the generated code The server functions to implement SyncRequest SyncResponse Sync() Watch() Swift client iOS, ready to call SyncRequest SyncResponse sync() watch() Kotlin client Android, ready to call SyncRequest SyncResponse sync() watch()
The contract is written once, and everyone gets their own version: the server, the iOS app, the Android app. Same messages, same fields, same functions. (Names simplified: each language adapts casing and prefixes to its own conventions.)

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.

A gRPC call. The app calls client.sync like an ordinary function; the request travels as binary protobuf over an HTTP/2 connection opened once and reused; the server runs the Sync function and sends back the response. The app client.sync(request) SyncRequest · binary protobuf one HTTP/2 connection, opened once, reused SyncResponse The server func Sync(ctx, req)
The app calls 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 same message in JSON and in protobuf. In JSON, {"id":42,"name":"Awa","premium":true} takes 37 bytes, including the field names, repeated in every message. In protobuf, it takes 9 bytes: each name is replaced by a one-byte field number. JSON 37 bytes { " i d " : 4 2 , " n a m e " : " A w a " , " p r e m i u m " : t r u e } Protobuf 9 bytes 08 2A 12 03 41 77 61 18 01 field names, repeated in every message the field number (1, 2, 3), one byte, replaces its name
The same message, uncompressed. The real gap depends on the messages, but the principle holds: protobuf sends only numbers and values.

The rest fits in a table. On almost every row, gRPC does by design what REST asks you to add:

For the mobile appRESTGraphQLgRPC
Compact messages
Battery
JSON, textJSON, only the requested fieldsCompact binary
One connection for every call
BatteryPerformance
Possible, over HTTP/2Possible, over HTTP/2Always: HTTP/2 is its transport
The server pushing
Battery
To be added (SSE, WebSocket)Subscriptions, often over WebSocketStreams 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 yourselfBuild it yourselfBuilt into the protocol
In a browserNativeNativeVia gRPC-Web or Connect
Reading and testing by handcurl, everywhereGood dedicated toolsDedicated 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:

You can no longer read your requestsBinary can’t be read by eye, and curl is no longer enough. You need grpcurl, reflection enabled on the server, or a GUI client that understands gRPC.
No HTTP cachinggRPC calls go over POST: caches and CDNs become useless. Anything that must be cached publicly has no business in gRPC.
Load balancing gets harderHTTP/2 connections are long-lived. A balancer working at the connection level pins a client to one instance. You have to balance call by call, or on the client side.

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.

The question you ask first gRPC or REST?
The right question What if we kept both, each where it’s best?

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 same diagram, with both protocols. On the left, in the gRPC circle, the iOS and Android app, which calls Sync and Watch: almost all the traffic, plus a few REST calls. On the right, in the REST circle, the admin panel and third-party services, which exchange JSON. In the middle, a single backend: one port, one binary, the same business logic. gRPC REST Sync · Watch a few REST calls JSON JSON The app iOS Android The backend one port, one binary the same business logic Admin panel the team, in a browser Third-party services webhooks, events
Two protocols, one backend. The phones also use REST, for a few specific needs.

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:

  1. In the foregroundWatch is open

    The server pushes what changes. No more “anything new?” every thirty seconds.

  2. In the backgroundThe system suspends the app

    The stream eventually closes. If something important comes up, a push notification (APNs, FCM) takes over.

  3. Back in frontSync, then Watch

    A single round trip catches up on what was missed, and the stream resumes.

The keepalive trap. Having the client ping every ten seconds to keep the connection open means waking the radio every time: exactly what we wanted to avoid. grpc-go actually cuts off clients that ping more often than its policy allows (five minutes by default). On mobile, it’s better to let the connection die in the background and reopen it on return.

Why this mix weighs on the choice of language

This is where the protocol catches up with the language:

§ 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 options
§ 3 · options

Go 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?

Option A

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 not nil.
  • The if err != nil repeated 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.
Option B

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, Send constraints, 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.

Go
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
}
Rust
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.

One more criterion

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 criteria
§ 4 · criteria

The 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 edge
Gogrpc-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.
Rusttonic, 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:
  1. Until nowtonic

    The reference library, driven by the community.

  2. May 2026tonic joins the gRPC project

    And moves to maintenance mode: fixes only.

  3. ComingAn official grpc crate

    With 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

Tie
Gonet/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.
Rustaxum, 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

Tie
GoSeveral proven solutions: route connections with cmux, 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.
Rusttonic and axum sit on the same foundations (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 reasons
GoMemory-safe (outside unsafe 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.
RustStronger guarantees, at compile time: no data race, no null pointer. 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 edge
GoFast, with a reasonable memory footprint. GOMEMLIMIT lets you tell the garbage collector how much memory the container can use, and the new collector cuts its cost further. Near-instant startup.
RustMore frugal, no question: less memory, no collection, more requests per core. For a service that computes, the gap can be large.

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 edge
GoTrivial cross-compilation (GOOS, 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.
RustStatic binary and tiny image too. Cross-compilation takes more setup (targets, linker, or tools like 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 edge
GoIn the 2025 Stack Overflow survey, 16.4% of respondents say they use Go. More importantly, Go is quick to learn: its specification is short, and a backend developer coming from Java, PHP or Node.js can contribute to a Go codebase very early, because there’s almost nothing to guess.
Rust14.8% of respondents use Rust, and for years it has been the most “admired” language in the same survey (72% in 2025). At Google, more than two thirds of developers who moved to Rust felt able to contribute within two months or less; but only about a third were as productive as in their previous language after two months, and just over half after four.

The 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

CriterionGoRustFor this project
gRPC ecosystemOfficial, complete, stableVery good, moving to an official libraryGo
RESTnet/http, Chiaxum, actix-webTie
One portcmux, ServeHTTP, Connecttonic + axum on towerTie
SecuritySmall surface, good toolingCompiler guaranteesTie
Memory, latencyGarbage collector, well tunedNo collection, very frugalRust
Feedback loopNear-instant compilationLong compilationGo
OperationsSimple, built-in profilingGood, more demandingGo
Tomorrow’s teamQuickly productiveLonger to trainGo
Domain modelingModest typesEnums, Option, ResultRust

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.

Go—
Rust—

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 decision
§ 5 · decision

Go, without regret, but not without hesitation

Decision

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

  1. The best gRPC support, today

    That’s the reason that tipped the scales. gRPC is the heart of the backend, and grpc-go has 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.

  2. 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.

  3. 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.

  4. 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. govulncheck does the rest.

  5. 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.

  6. 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.

I give up: a compiler that forbids data racesIn Rust, an unprotected concurrent access doesn’t compile.
I compensate with: Go’s race detector, everywherego test -race in CI, on the whole test suite, and load tests that enable it too.
I give up: Option, Result and real enumsModeling an exhaustive state, and letting the compiler check that no case is forgotten.
I compensate with: linters and conventionsstaticcheck and golangci-lint, Uber’s nilaway analysis against nil pointers, and explicitly written state machines, with one test per transition.
I give up: having no garbage collectorPerfectly steady latency and memory kept to the bare minimum.
I compensate with: measuring before optimizingGOMEMLIMIT set to the container size, pprof in production, and allocations watched on the busiest paths.
I give up: “if it compiles, it works”That rare comfort Rust gives.
I compensate with: tests, and more testsTable-driven tests, built-in fuzzing (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:

Understood afterwards

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.

The appthe .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 towardBecause
An API backend, microservices, a growing teamGoReadability, onboarding speed, cloud and gRPC ecosystem.
An MVP to ship fast, with a generalist teamGoThe feedback loop and the learning curve come first.
A command-line tool, an infrastructure toolBothTwo excellent choices; the team decides.
A proxy, an engine, a latency-critical serviceRustNo collection, steady latency, every cycle counts.
Embedded, systems, WebAssemblyRustNo heavy runtime, fine-grained memory control.
A library shared between iOS and AndroidRustOne 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 · Implementation
§ 6 · implementation

The 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.

Go grpc-go Protocol Buffers + Buf Chi cmux OpenTelemetry Distroless image

Overview: one port, two servers, one logic

The path of a connection. It arrives on port 8080. cmux reads its first bytes: if it is HTTP/2 with the content-type header application/grpc, it goes to the gRPC server and its interceptors; otherwise, to the standard HTTP server and the Chi router with its middlewares. Both call the same business service layer, which accesses the data. :8080 incoming connection cmux reads the start HTTP/2 + application/grpc grpc.Server interceptors: auth, traces, logs everything else http.Server + Chi middlewares: auth, rate limit, CORS internal/service business logic, no gRPC or HTTP data
Each server thinks it has its own port: cmux hands them their connections. The business logic doesn’t even know which door the request came through.

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.

In CI, on every pull request
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

Routing happens per connection

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.

Clients that wait for the server

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.

TLS terminates upstream

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.

Graceful shutdown and long streams

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.

Two health checks

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.

A consistent keepalive

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.
ConnectRPCVery appealing: generated services are plain 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.
A proxy in front (Envoy)Routing by 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.
grpc-gatewayGenerates a REST API from contract annotations. Valuable for exposing the same methods over REST. My REST, though, serves other needs (admin, webhooks): generating it from the app’s contract would mix them up.

The tree and the image

The repository tree
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
Dockerfile
FROM 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 · Consequences
§ 7 · consequences

What 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 last word
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.
Rivel Babindamana
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.