Why I Chose Go Over Rust for My SaaS Backend (And When I Regretted It)

January 22, 2025
8 min read
15,234 views
567 likes
Go
Rust
Backend
Decision Making

After 18 months of building production services in both Go and Rust, I have strong opinions about when to choose each language. This isn't another benchmark comparison or syntax tutorial. This is the brutally honest truth about building real systems with real constraints.


Spoiler: the answer isn't what you think, and it has nothing to do with performance benchmarks.


The Context That Changes Everything


I'm building NextDeploy, a deployment platform that needs to:

  • Handle thousands of concurrent builds
  • Process Docker images efficiently
  • Manage complex state across distributed systems
  • Scale from 0 to millions of requests
  • Be maintained by a small team

  • Both Go and Rust could handle these requirements. The question was: which would let us ship faster and sleep better?


    Why I Initially Chose Go


    Developer Velocity

    Go's simplicity is its superpower. New team members can contribute meaningfully within days, not weeks. The language has one way to do most things, which eliminates bikeshedding and reduces cognitive load.


    // Go: Simple and readable

    func processDeployment(ctx context.Context, req DeploymentRequest) error {

    build, err := buildImage(ctx, req.RepoURL)

    if err != nil {

    return fmt.Errorf("build failed: %w", err)

    }


    if err := deployContainer(ctx, build.ImageID); err != nil {

    return fmt.Errorf("deploy failed: %w", err)

    }


    return nil

    }


    Compare this to the equivalent Rust code:


    // Rust: More explicit, more complex

    async fn process_deployment(

    ctx: Context,

    req: DeploymentRequest,

    ) -> Result<(), DeploymentError> {

    let build = build_image(&ctx, &req.repo_url)

    .await

    .map_err(DeploymentError::BuildFailed)?;


    deploy_container(&ctx, &build.image_id)

    .await

    .map_err(DeploymentError::DeployFailed)?;


    Ok(())

    }


    The Rust version is more explicit about error handling and async behavior, but it's also more verbose and requires more mental overhead.


    Ecosystem Maturity

    Go's ecosystem for backend services is incredibly mature. Need a web framework? Gin or Echo. Database ORM? GORM. Message queues? Go has excellent libraries for everything.


    Rust's ecosystem is growing rapidly, but it's still catching up. Finding the "blessed" way to do common tasks often requires research and experimentation.


    Operational Simplicity

    Go produces single binaries with no runtime dependencies. Deployment is as simple as copying a file. Docker images are tiny. Memory usage is predictable.


    # Go: Simple deployment

    FROM scratch

    COPY nextdeploy /

    ENTRYPOINT ["/nextdeploy"]


    This operational simplicity saved us countless hours in deployment and debugging.


    Where Go Started to Hurt


    Memory Management

    Go's garbage collector is excellent, but it's still a garbage collector. For our build service, which processes large Docker contexts, GC pauses became noticeable under load.


    We had builds that would pause for 50-100ms during GC cycles. For most applications, this is fine. For a system that needs to feel instant, it was death by a thousand cuts.


    Error Handling Verbosity

    Go's explicit error handling is a feature, not a bug. But after writing thousands of lines of error handling code, the verbosity becomes exhausting:


    func complexOperation() error {

    result1, err := step1()

    if err != nil {

    return fmt.Errorf("step1 failed: %w", err)

    }


    result2, err := step2(result1)

    if err != nil {

    return fmt.Errorf("step2 failed: %w", err)

    }


    result3, err := step3(result2)

    if err != nil {

    return fmt.Errorf("step3 failed: %w", err)

    }


    return nil

    }


    This pattern repeated hundreds of times across our codebase. The signal-to-noise ratio started to suffer.


    Performance Ceiling

    Go is fast enough for most use cases, but it has a performance ceiling. When we needed to process thousands of Docker layers per second, Go's runtime overhead became the bottleneck.


    The Rust Experiment


    Six months into building NextDeploy, I rewrote our build service in Rust. Here's what I learned:


    Performance is Incredible

    The Rust version was 3-4x faster than the Go version for CPU-intensive tasks. More importantly, it had predictable performance with no GC pauses.


    // Rust: Zero-cost abstractions in action

    fn process_layers(layers: &[Layer]) -> Result<ProcessedLayers, Error> {

    layers

    .par_iter() // Parallel processing with rayon

    .map(|layer| process_layer(layer))

    .collect::<Result<Vec<_>, _>>()

    .map(ProcessedLayers::new)

    }


    The parallel processing capabilities with Rayon made complex operations trivial to optimize.


    Memory Safety Without Runtime Cost

    Rust's ownership system caught bugs that would have been runtime panics in Go:


    // This won't compile - Rust prevents data races at compile time

    fn broken_example() {

    let mut data = vec![1, 2, 3];

    let reference = &data[0];

    data.push(4); // Error: cannot borrow as mutable

    println!("{}", reference);

    }


    This compile-time safety eliminated entire classes of bugs that plagued our Go services.


    The Learning Curve is Real

    It took our team 3 months to become productive in Rust. The borrow checker is unforgiving, and the type system requires a different way of thinking about problems.


    We had senior engineers struggling with concepts that would be trivial in other languages. The productivity hit was significant.


    The Honest Comparison


    Choose Go When:

  • **Team velocity matters more than raw performance**
  • **You need to hire developers quickly**
  • **Operational simplicity is a priority**
  • **You're building typical web services or APIs**
  • **Time to market is critical**

  • Choose Rust When:

  • **Performance is a hard requirement**
  • **You're building system-level software**
  • **Memory safety is critical**
  • **You have experienced systems programmers**
  • **Long-term maintenance matters more than short-term velocity**

  • What I Actually Did


    Here's the plot twist: I kept both.


    Our architecture now uses Go for the API layer and coordination services, and Rust for the performance-critical build engine. This hybrid approach gives us the best of both worlds:


  • **Go services** handle HTTP requests, database operations, and business logic
  • **Rust services** handle Docker image processing, file system operations, and CPU-intensive tasks
  • **gRPC** provides type-safe communication between services

  • // Go service calls Rust service via gRPC

    func (s *APIServer) handleBuild(c *gin.Context) {

    req := &buildpb.BuildRequest{

    RepoUrl: c.PostForm("repo_url"),

    Branch: c.PostForm("branch"),

    }


    resp, err := s.buildClient.Build(c.Request.Context(), req)

    if err != nil {

    c.JSON(500, gin.H{"error": err.Error()})

    return

    }


    c.JSON(200, gin.H{"build_id": resp.BuildId})

    }


    The Metrics That Matter


    After 12 months of running this hybrid architecture:


  • **API response times**: 95th percentile under 100ms (Go)
  • **Build processing**: 4x faster than pure Go (Rust)
  • **Memory usage**: 40% reduction overall
  • **Developer productivity**: Maintained high velocity
  • **Bug rate**: 60% reduction in memory-related issues

  • Lessons Learned


    Language Choice is a Business Decision

    The "best" language is the one that helps your team ship reliable software quickly. Technical superiority doesn't matter if it slows down your business.


    Polyglot Architectures Work

    You don't have to choose one language for everything. Use the right tool for each job, and invest in good interfaces between components.


    Team Skills Matter More Than Language Features

    A great Go developer will build better systems than a mediocre Rust developer, regardless of language capabilities.


    Performance Optimization is Iterative

    Start with the simpler language and optimize bottlenecks as you find them. Premature optimization is still the root of all evil.


    The Uncomfortable Truth


    Most applications don't need Rust's performance. Most teams can't afford Rust's learning curve. Most businesses prioritize shipping over perfection.


    Go is the pragmatic choice for most backend services. Rust is the right choice when Go isn't fast enough.


    The uncomfortable truth is that language choice matters less than we think. Good architecture, clear interfaces, and solid engineering practices matter more than syntax and performance benchmarks.


    What I'd Tell My Past Self


    If I could go back 18 months, I'd still choose Go first. But I'd plan for the hybrid architecture from the beginning:


    1. **Start with Go** for rapid prototyping and business logic

    2. **Design clean interfaces** between components

    3. **Identify performance bottlenecks** through measurement, not assumption

    4. **Rewrite critical paths in Rust** when Go becomes the bottleneck

    5. **Invest in tooling** to make the polyglot architecture manageable


    The Real Winner


    The real winner isn't Go or Rust. It's having the flexibility to choose the right tool for each problem.


    Build systems that can evolve. Design interfaces that can survive implementation changes. Focus on solving customer problems, not language wars.


    Your users don't care what language you use. They care that your software works reliably and performs well. Everything else is just implementation details.


    ---


    *What's your experience with Go vs Rust in production? I'd love to hear your stories—the good, the bad, and the ugly. Reach out to me at yussuf@hersi.dev or on Twitter [@yussufhersi](https://twitter.com/yussufhersi).*