Best practices for concurrency, garbage collection tuning, and horizontal scaling in Go.
Introduction: The Go Advantage
Go (Golang) has emerged as the language of choice for building high-performance, scalable backend systems. Companies like Uber, Dropbox, and Netflix have migrated critical services to Go, citing dramatic improvements in throughput, latency, and resource utilization. But scaling Go applications to handle millions of concurrent users requires more than just writing idiomatic code—it demands a deep understanding of the runtime, careful resource management, and architectural discipline.
In this comprehensive guide, we'll explore the battle-tested strategies we've used at SVV Global to scale Go services from thousands to millions of requests per second, while maintaining sub-100ms p99 latencies and keeping infrastructure costs manageable.
Understanding Goroutines: Lightweight but Not Free
Goroutines are Go's secret weapon for concurrency. Unlike OS threads, which consume 1-2MB of stack space each, goroutines start with just 2KB. This allows you to spawn millions of them on a single machine. However, this doesn't mean they're free.
Each goroutine still consumes memory, and more importantly, they compete for CPU time. We learned this the hard way when a poorly designed worker pool spawned 10 million goroutines, exhausting the heap and causing the garbage collector to thrash. The fix was implementing bounded concurrency using semaphores and worker pools.
Worker Pool Pattern
Instead of spawning a goroutine for every incoming request, we use a fixed-size worker pool. Here's the pattern:
Create a buffered channel as a job queue, spawn N worker goroutines that consume from this queue, and limit the number of concurrent operations to a manageable level. This prevents goroutine explosion and provides backpressure when the system is overloaded.
Garbage Collection: The Hidden Performance Killer
Go's garbage collector has improved dramatically over the years, but at high throughput, GC pauses can still be a bottleneck. The key metric to watch is GC pause time. If your p99 latency spikes correlate with GC cycles, you have a problem.
Tuning GOGC
The GOGC environment variable controls how aggressively the GC runs. The default value is 100, meaning the GC triggers when the heap size doubles. Increasing this to 200 or 300 reduces GC frequency but increases memory usage. We found that GOGC=200 was the sweet spot for our workloads, reducing GC cycles by 40% with only a 20% increase in memory footprint.
Reducing Allocations
The best way to reduce GC pressure is to allocate less. Use pprof to identify allocation hotspots. Common culprits include string concatenation in loops (use strings.Builder instead), unnecessary slice growth (pre-allocate with make), and boxing primitives into interfaces.
We also adopted sync.Pool for frequently allocated objects like HTTP request/response buffers. This reduced our allocation rate from 5GB/s to 500MB/s, cutting GC time by 60%.
Horizontal Scaling: Stateless Services
The easiest way to scale is horizontally—add more instances. But this only works if your services are stateless. Any in-memory state (sessions, caches) must be externalized to Redis or a similar distributed store.
Load Balancing Strategies
We use NGINX as a reverse proxy with least-connections load balancing. For WebSocket connections, we use consistent hashing to ensure that all connections from a user land on the same backend instance, maximizing cache locality.
Service Discovery
In a dynamic environment where instances come and go, hard-coded IPs don't work. We use Consul for service discovery. Services register themselves on startup, and clients query Consul to find healthy instances. This allows us to deploy new versions with zero downtime using blue-green deployments.
Database Optimization: The Bottleneck
No matter how fast your Go code is, if you're making synchronous database calls for every request, you'll hit a wall. Here's how we optimized our database layer:
Connection Pooling
Go's database/sql package includes connection pooling, but the defaults are conservative. We tuned SetMaxOpenConns to match our database's max connections, and SetMaxIdleConns to keep connections warm. This eliminated the overhead of establishing new connections for every query.
Read Replicas
90% of our queries are reads. We route read traffic to PostgreSQL replicas, reserving the primary for writes. This distributes the load and allows us to scale read capacity independently by adding more replicas.
Caching with Redis
For frequently accessed data that doesn't change often (user profiles, product catalogs), we implemented a Redis cache with a 5-minute TTL. This reduced database load by 70% and improved response times from 50ms to 5ms for cached requests.
Observability: You Can't Fix What You Can't See
At scale, intuition fails. You need metrics, logs, and traces to understand system behavior.
Prometheus Metrics
We instrument every service with Prometheus metrics: request count, latency histograms, error rates, and custom business metrics. Grafana dashboards give us real-time visibility into system health.
Distributed Tracing
When a request spans 10 microservices, finding the bottleneck is impossible without distributed tracing. We use OpenTelemetry to trace requests end-to-end. This helped us identify that 80% of our latency was coming from a single slow database query in the recommendation service.
Real-World Results
After implementing these optimizations, we scaled our Go-based API gateway from handling 10,000 requests per second to 500,000 requests per second on the same hardware. Latency improved from 200ms p99 to 50ms p99. Infrastructure costs dropped by 40% as we consolidated workloads onto fewer, more efficiently utilized instances.
Conclusion: Scaling is a Journey
Scaling Go applications isn't about a single silver bullet—it's about systematic optimization across the entire stack. Profile your code, understand your bottlenecks, and iterate. The combination of Go's excellent concurrency primitives, careful resource management, and sound architectural patterns can take you from prototype to planet-scale.
The Future of Go: WebAssembly and Generics
Looking forward, the Go ecosystem is expanding in exciting new directions. With the stabilization of WebAssembly support, we're seeing Go being used for high-performance frontend logic and edge computing. Generics, introduced in Go 1.18, have matured significantly, allowing us to write more reusable, type-safe data structures without sacrificing performance.
We are also keeping a close eye on the Go runtime's experimental features, such as the new soft memory limit and arena-based allocation. These features promise even tighter control over memory usage, which will be critical as we push towards handling 10 million concurrent users on a single cluster.
Final Thoughts: The Culture of Performance
The most important part of scaling is not the code—it's the culture. Performance should not be an afterthought; it should be a first-class citizen in your development process. By integrating performance testing into your CI/CD pipeline and fostering a culture of profiling and optimization, you can ensure that your application remains fast and responsive as it grows.
At SVV Global, we don't just help you fix your scaling issues; we help you build the processes and infrastructure to prevent them from happening in the first place.
If you're facing scaling challenges or just want to build something that lasts, our Go solutions team is ready to help you navigate the complexity of modern backend engineering. Let's build the future together.
Found this helpful?
Share this article with your network
