Back to Writing
06 — Engineering

Why I Still Reach for Go When Rails Isn't Enough

Cyrus Kiprop·June 21, 2026·2 min read

I like Rails. I ship most product work in it, and I'll defend that choice against most "rewrite it for performance" arguments. But there's a specific category of problem where I stop defending Rails and reach for Go instead, and it's worth being precise about what that category actually is.

It's not about raw speed

The instinct is to frame this as "Go is faster," and leave it there. That's true but it's not the actual decision driver — plenty of Rails services run fine at real production load. The actual trigger is concurrency shape, not raw throughput.

  • A service holding thousands of long-lived connections open at once (websocket fan-out, an event gateway aggregating many upstream feeds).
  • Work that's naturally expressed as many small concurrent tasks coordinating with channels, not a request/response cycle with a database on the other end.
  • A CPU-bound hot path where the GIL-equivalent cost in a Rails process actually shows up in a flame graph.

If none of those are true, rewriting in Go is usually just moving the same problem into a language with fewer libraries for it.

What actually made NexusMesh a Go service

The event gateway handling 250K peak RPS wasn't slow in Rails because Rails is slow — it was a bad fit for Rails, because the workload was thousands of concurrent long-lived connections doing rate-limiting and light transformation, not database-backed request handling. Goroutines and channels are a direct match for that shape of problem in a way that a thread-per-request model isn't, regardless of language.

The actual rate limiter is small enough to hold in your head, which is the point — no thread pool tuning, no async runtime configuration, just a channel doing the job a queue would do in most other languages:

go
type RateLimiter struct {
    tokens chan struct{}
}

func NewRateLimiter(burst int, refillEvery time.Duration) *RateLimiter {
    rl := &RateLimiter{tokens: make(chan struct{}, burst)}
    for i := 0; i < burst; i++ {
        rl.tokens <- struct{}{}
    }

    go func() {
        ticker := time.NewTicker(refillEvery)
        for range ticker.C {
            select {
            case rl.tokens <- struct{}{}:
            default: // bucket full, drop the tick
            }
        }
    }()

    return rl
}

func (rl *RateLimiter) Allow() bool {
    select {
    case <-rl.tokens:
        return true
    default:
        return false
    }
}

Every connection checks Allow() against the same limiter without a mutex, a lock file, or a Redis round-trip. That's the whole pitch for Go in this specific shape of problem — not that it's faster, but that concurrency primitives are load-bearing parts of the standard library instead of an add-on.

The rule I use now: if I can describe the service without mentioning a database query, and the bottleneck is connection concurrency rather than business logic, that's usually a Go service. Everything else stays in Rails, because rewriting product logic in a lower-level language rarely pays for the velocity it costs.