Discord: Why Discord is Switching from Go to Rust
Problem + constraints
Discord's Read States service tracks, per user per channel, what has been read — it's hit on every connect, every message send, and every message read, which at Discord's scale meant millions of users, tens of millions of Read States held in an LRU cache, and hundreds of thousands of cache updates per second. The original implementation was in Go and was fast on average, but the service exhibited periodic latency and CPU spikes roughly every two minutes — regular enough that the pattern was clearly structural, not incidental load.
Profiling traced the spikes to Go's garbage collector. Go's runtime doesn't free memory immediately when a cache entry is evicted from the LRU; instead it waits for a GC cycle to identify and reclaim unreferenced memory, and Go guarantees a forced collection at least every two minutes even under otherwise-idle conditions. With tens of millions of live entries, that periodic collection had to scan a very large heap, and the scan itself produced the CPU/latency spikes. The team tried the standard Go tuning levers — adjusting GOGC (the heap-growth ratio that controls collection frequency) and sharding the cache to shrink what any single collection had to scan. Both reduced the size of individual spikes, but sharding in particular came at a real cost: splitting one large LRU into shards lowers the effective cache hit rate (a shard can evict an item that would still be hot in a unified cache), which is exactly the kind of tradeoff that trades a visible symptom (spikes) for a less-visible one (worse steady-state latency from more cache misses).
Solution
Discord rewrote the Read States service in Rust. Rust has no garbage collector — memory is freed deterministically via ownership and RAII the instant a value's owner goes out of scope, so when an entry is evicted from the LRU cache it is freed immediately as part of that same operation, not scanned-for and reclaimed later in a separate, unpredictable pass. There is no periodic collection to synchronize with cache size, and therefore no structural reason for a spike to occur every two minutes.
Even a fairly direct, not-yet-deeply-optimized Rust port outperformed the heavily hand-tuned Go version: the rewrite eliminated the periodic latency spikes entirely and delivered roughly a 10x reduction in memory usage alongside a 90% reduction in the size of the (now rare) latency spikes that remained. The memory win came from the same root cause as the latency win — Go's GC keeps a generational safety margin and doesn't reclaim memory as aggressively as a language with no collector at all, so the steady-state RSS for the same live-data volume was substantially higher under Go.
What to steal
- Match the tool's memory model to the workload's shape, not just its throughput needs. A large, high-churn cache with strict latency SLOs is exactly the workload where GC-driven "stop the world, scan everything" behavior is most visible — Go's GC works well for many workloads and is a poor fit for exactly this one.
- Exhaust in-language tuning before rewriting, and treat that tuning as diagnostic, not just remedial: Discord's
GOGC/sharding attempts didn't just fail to fully fix the problem, they helped confirm the GC was the actual root cause (spike size scaled with what a collection had to scan) before committing to a rewrite. - A tradeoff that "reduces the symptom" isn't free — sharding the cache made spikes smaller but quietly cost hit rate; any mitigation that changes a data structure's shape to work around a runtime behavior needs its own before/after measurement, not just a check that the original symptom shrank.
- Language migrations for a single hot service are viable when the service is narrow and well-isolated — Read States has a small, well-defined API surface, which is what made a full-service rewrite in a different language a bounded, low-blast-radius decision rather than an organization-wide one.
Engineering Lens
The lesson generalizes past "Rust beats Go": it's about recognizing when a periodic, workload-correlated performance problem is a property of the runtime's memory model, not the code sitting on top of it, and confirming that hypothesis with tuning experiments before reaching for a rewrite. Any team defending a similar decision in review should be able to show the same shape of evidence Discord did — an observed pattern (spikes every ~2 minutes), a mechanism connecting it to the runtime (forced GC on a large heap), a documented attempt to mitigate within the existing runtime, and a measured result, not just "our profiler says Rust is faster."