March 27, 2025
Go
Concurrency
Performance
If you've written more than a few lines of Go code, you've probably fallen in love with goroutines. They're lightweight, easy to spawn, and make concurrent programming feel almost magical. But remember what Uncle Ben said—with great power comes great responsibility. If you don't control them, goroutines can multiply unchecked and turn your application into a resource-hogging monster.
Imagine you're building a feature for your application where a user can select 10 files (~10MB each) to back up to cloud storage (like S3 or Google Cloud Storage). Your first implementation might look like this perfectly reasonable, straightforward code:
func UploadFiles(r *gin.Engine, bucketSrv bucket.Service) {
r.POST("/api/files", func(c *gin.Context) {
ctx := c.Request.Context()
files := ginx.GetFiles(c)
for file := range files {
if err := bucketSrv.Upload(ctx, files[file]); err != nil {
responsex.RenderFailure(c, err)
return
}
}
responsex.RenderSuccess(c, http.StatusCreated, res)
})
}
This works, but it's painfully slow. The total time for the HTTP request is the sum of all individual upload times. Your server spends most of its life waiting—waiting for network packets to travel to the cloud storage API and back. This is inefficient and provides a poor user experience.
Naturally, you reach for goroutines. "I'll just run all the uploads concurrently!" you think. And it's a great thought. You quickly refactor the loop.
func UploadFiles(r *gin.Engine, bucketSrv bucket.Service) {
r.POST("/api/files", func(c *gin.Context) {
ctx := c.Request.Context()
files := ginx.GetFiles(c)
var wg sync.WaitGroup
errCh := make(chan error, len(files))
for _, file := range files {
wg.Add(1)
go func(f *multipart.FileHeader) {
defer wg.Done()
if err := bucketSrv.Upload(ctx, f); err != nil {
errCh <- err
}
}(file)
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
responsex.RenderFailure(c, err)
return
}
}
responsex.RenderSuccess(c, http.StatusCreated, true)
})
}
This is much faster! By launching a goroutine for each task, we do all the waiting in parallel, drastically reducing the total request time.
The refactored handler solved the speed issue for a single user, but it created a massive stability problem for your service.
Now imagine 1,000 users each uploading 10 files at the same time—that's 10,000 concurrent uploads in an instant. The server becomes overloaded, CPU and memory spike, the cloud API rejects requests with 429 errors, and users experience high failure rates or inconsistent results. Even simple endpoints start failing because resources are drained.
In short, a solution for speed has turned into a system-wide stability nightmare—the classic "thundering herd" problem. Without control over concurrency, your service becomes unpredictable and fragile under load.
The solution is to control the chaos by limiting how many uploads can happen at once. Instead of spawning a goroutine for every single file, we create a fixed number of "worker" goroutines and feed them files through a channel. This is called a worker pool pattern.
Here's the straightforward approach:
func UploadFiles(r *gin.Engine, bucketSrv bucket.Service) {
r.POST("/api/files", func(c *gin.Context) {
ctx := c.Request.Context()
files := ginx.GetFiles(c)
const workerCount = 3
jobs := make(chan *multipart.FileHeader, len(files))
errCh := make(chan error, len(files))
var wg sync.WaitGroup
// start workers
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for file := range jobs {
if err := bucketSrv.Upload(ctx, file); err != nil {
errCh <- err
}
}
}()
}
// send jobs
for _, file := range files {
jobs <- file
}
close(jobs)
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
responsex.RenderFailure(c, err)
return
}
}
responsex.RenderSuccess(c, http.StatusCreated, true)
})
}
We create exactly 3 worker goroutines that continuously read files from the jobs channel. All uploaded files are sent into this channel. The workers process them concurrently, but never more than 3 at a time. If any upload fails, the error gets sent to a separate channel and the entire request fails.
Your system now handles any number of users and files predictably. Instead of crashing under load, it maintains a steady, manageable pace. The cloud API stays happy, your server resources stay stable, and users get reliable performance instead of random failures. You get the speed of concurrency without the instability.
In a production-ready system, the worker pool pattern is non-negotiable for managing concurrent operations. It provides the crucial balance between performance and stability, ensuring your system can handle scale without becoming its own worst enemy. This approach transforms goroutines from a potential source of chaos into a predictable, managed resource that protects both your application and its dependencies from overload.
The key insight is that effective concurrency isn't about maximizing parallel execution—it's about finding the optimal throughput for your specific context. The right worker count depends on your resource constraints, external API limits, and performance requirements. Start conservatively, monitor under real load, and iterate toward the sweet spot where efficiency meets reliability. This disciplined approach to concurrency is what separates hobby code from production-grade systems.