Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 068 / 100

Go vs Rust for backend services: lessons from production

Simplicity vs control.

Expona Admin7 min read

We've spent the last decade building backend services in Go, and we've watched Rust gain serious traction in the ecosystem. Both languages excel at solving different problems, and the question isn't really which is better—it's which fits your constraints. In this article, we'll share what we've learned about Go vs Rust for backend services: lessons from production, drawing on real deployments where we've made both choices and lived with the consequences.

Go: fast to learn and ship

Go was designed for systems where time-to-market and team velocity matter. The language is deliberately minimal—no complex type systems, no macro systems, no dependency hell. When you hire a competent backend engineer, they can ship production Go code within their first week. That's not hyperbole; it's a founding principle of the language.

Consider a typical microservice: an API that reads from a database, applies some business logic, and returns JSON. In Go, you write it directly, without fighting the compiler or wrestling with borrow checkers.

go
package main

import (
	"database/sql"
	"encoding/json"
	"net/http"
	"github.com/gorilla/mux"
	_ "github.com/lib/pq"
)

type Order struct {
	ID     int    `json:"id"`
	Status string `json:"status"`
	Total  float64 `json:"total"`
}

func getOrder(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		vars := mux.Vars(r)
		id := vars["id"]
		
		order := &Order{}
		err := db.QueryRow("SELECT id, status, total FROM orders WHERE id = $1", id).
			Scan(&order.ID, &order.Status, &order.Total)
		
		if err == sql.ErrNoRows {
			http.Error(w, "Not found", http.StatusNotFound)
			return
		}
		if err != nil {
			http.Error(w, "Database error", http.StatusInternalServerError)
			return
		}
		
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(order)
	}
}

func main() {
	db, _ := sql.Open("postgres", "...")
	defer db.Close()
	
	router := mux.NewRouter()
	router.HandleFunc("/orders/{id}", getOrder(db)).Methods("GET")
	http.ListenAndServe(":8080", router)
}

This code is readable, straightforward, and works immediately. Most of the team understands it without explanation. Onboarding is fast. Debugging is straightforward because the runtime gives you clear stack traces and goroutines are lightweight enough that you can spawn thousands without fear.

The Go runtime also handles concurrency elegantly. Goroutines cost roughly 2KB each, compared to OS threads at 1-2MB. This means you can write concurrent code naturally without premature optimization:

go
func processOrderBatch(orders []Order) {
	for _, order := range orders {
		go func(o Order) {
			// process each order concurrently
			validateAndSave(o)
		}(order)
	}
}

In production, this matters. We've deployed Go services that handle millions of requests per day on modest hardware, all because the concurrency model is baked into the language and runtime. Garbage collection is generational and tunable. Cold starts are milliseconds, not seconds.

Rust: memory safety without GC

Rust offers something Go doesn't: guaranteed memory safety at compile time, with zero runtime overhead. There's no garbage collector. There's no null pointer dereference. The compiler enforces these guarantees through the borrow checker, which can feel punishing until you internalize its logic.

When you need sub-millisecond latencies, or when you're processing terabytes of data per day on a fixed hardware budget, Rust's lack of GC pauses becomes a feature worth the friction.

Here's a similar order lookup in Rust using Actix-web and SQLx:

go
use actix_web::{web, App, HttpServer, HttpResponse};
use sqlx::PgPool;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, sqlx::FromRow)]
struct Order {
    id: i32,
    status: String,
    total: f64,
}

async fn get_order(pool: web::Data, id: web::Path) -> HttpResponse {
    let order = sqlx::query_as::<_, Order>(
        "SELECT id, status, total FROM orders WHERE id = $1"
    )
    .bind(id.into_inner())
    .fetch_optional(&**pool)
    .await;

    match order {
        Ok(Some(o)) => HttpResponse::Ok().json(o),
        Ok(None) => HttpResponse::NotFound().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let pool = PgPool::connect("postgres://...").await.unwrap();
    
    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .route("/orders/{id}", web::get().to(get_order))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

The code is more verbose and the compiler is stricter. But once it compiles, you've eliminated entire categories of bugs: use-after-free, double-free, data races, integer overflow. The borrow checker forces you to think clearly about ownership and lifetimes.

In high-frequency trading systems, real-time analytics platforms, and data processing pipelines, this safety combined with predictable performance is invaluable. We've deployed Rust services that process order streams with sub-100-microsecond latencies, something Go can't guarantee due to GC pauses.

Go fits most web services

Let's be direct: if you're building a REST API, a gRPC service, a webhook processor, or a standard microservice, Go is the right default. The cognitive overhead of learning Go is minimal. The time from "first commit" to "production traffic" is measured in weeks, not months.

Go's standard library is surprisingly complete. You get HTTP, JSON marshaling, SQL, crypto, and testing built in. The ecosystem is mature—libraries like Chi for routing, sqlc for type-safe SQL, and OpenTelemetry for observability are production-ready. Deployment is simple: a single binary, no runtime dependencies, containerizes trivially.

We've found that Go services typically stabilize faster than their counterparts in other languages. The simpler type system means fewer design rabbit holes. The explicit error handling (though verbose) means fewer surprise nil panics in production. The race detector catches concurrent access bugs at development time.

In a e-commerce platform we worked on, we migrated several services from Node.js to Go. The result: 40% reduction in p99 latency, 60% reduction in memory usage, and the same team maintaining twice as many services. The payoff came not from micro-optimizations but from removing garbage collection pauses and simplifying concurrency.

Rust for performance-critical components

Rust shines when you hit Go's limits. Those limits are higher than most teams expect—usually you don't hit them until you're genuinely operating at scale—but they exist.

We've used Rust for:

  • Stream processors: Real-time aggregation of millions of events per second, where GC pauses would drop events or trigger backpressure.
  • Database proxies: Connection pooling and query rewriting layers that sit between application pools and a shared database, where latency and memory overhead compound.
  • Encryption/compression workers: Cryptographic operations and data compression for bulk exports, where the absence of GC and direct memory access yields measurable wins.
  • Protocol handlers: Custom binary protocol parsers and WebSocket forwarders, where Rust's zero-copy parsing prevents allocations.

In one case, we built a Kafka consumer that enriched events with cached data before forwarding to a data warehouse. The Go version consumed 8GB of memory and had occasional GC stalls pushing p99 latency to 2 seconds. The Rust rewrite used 400MB and maintained p99 below 50ms, because we could be explicit about memory allocation and avoid heap churn.

But here's the catch: Rust's learning curve is steep. A junior engineer won't contribute meaningfully to Rust code for months. The borrow checker fights you on nearly every design pattern borrowed from dynamic languages. Debugging is harder because you're often debugging logic that a GC would mask.

Use Rust as a targeted tool for performance-critical paths, not as your default. If you're reaching for Rust because "it's faster," ask first: have we measured? Have we profiled? Is the bottleneck actually where we think it is? More often than not, Go's simplicity buys you enough performance to solve the real problem: system design.

Common mistakes

Choosing Rust for velocity: Teams new to Rust vastly underestimate time-to-first-working-code. A simple service that would take a Go team two weeks takes a Rust team two months, assuming the team has Go experience but not Rust. Reserve Rust for scenarios where the performance gain is non-negotiable and measured.

Assuming Go is "good enough" without measuring: Conversely, don't write off Rust because you assume Go will handle your workload. Profile your Go services under realistic load. If you're seeing 500ms GC pauses regularly, Go might not be the right tool, and that's information worth having early.

Mixing languages without clear boundaries: Teams often create a Go service that calls out to a Rust library via FFI, or vice versa. This adds operational complexity and debugging pain without clear benefit. If you do use both languages in one system, give them separate microservices with well-defined RPC boundaries (gRPC, HTTP, queues). This makes it easier to reason about failure modes and redeploy components independently.

Overcomplicating Go code to avoid GC: Some teams try to eliminate allocations in Go by reusing buffers, object pools, and arena allocators. This is premature optimization in 99% of cases. Modern Go's allocator is fast. Fix actual bottlenecks, not theoretical ones.

Key takeaways

  • Go is the default for most backend services: it balances simplicity, performance, and time-to-market better than any alternative for web APIs and standard microservices.
  • Rust eliminates entire categories of bugs and provides predictable, GC-free performance, but the learning curve and longer development cycle make it a specialist tool.
  • Use Go unless you have a specific, measured reason not to—sub-millisecond latencies, enormous data throughput, or hard real-time constraints.
  • If you do use both, separate them cleanly: different services, clear RPC boundaries, no mixed-language builds.
  • Profile before you decide. "It's slow" is not the same as "the bottleneck is memory allocation." Different problems need different solutions.
  • Team familiarity matters. A Go service maintained by engineers who know Go well will outperform a Rust service maintained by a team still climbing the learning curve.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles