# Why Your Goroutines Need a Speed Limit: Bounded Concurrency in Go

> **Source:** [http://localhost:1313/bounded-concurrency-in-go/](http://localhost:1313/bounded-concurrency-in-go/)
> **Author:** [Vikash Patel](https://vikashpatel.net)
> **Published:** April 10, 2026
> **Reading Time:** 4 min
> 
> *This is the raw Markdown source of the article from the [Lorbic Technical Journal](http://localhost:1313/).*

---


**TL;DR:** Spawning `go func()` without a limiter is a recipe for system collapse. This guide details how to use **Semaphores** and **Worker Pools** to prioritize predictable stability over absolute speed, protecting downstream dependencies from the thundering herd.

---

It's a rite of passage for every Go developer. You receive a list of 10,000 URLs to fetch or 50,000 rows to process. You wrap the workload in an unbounded `go func()` loop, achieving maximum throughput in milliseconds.

```go
// Initial approach: unbounded concurrency
func ProcessItems(items []Item) {
    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)
        go func(i Item) {
            defer wg.Done()
            process(i) // HTTP request, DB write, etc.
        }(item)
    }
    wg.Wait()
}
```

This code runs flawlessly on a local machine. However, in production, it triggers **OOM (Out of Memory)** kills and database connection failures. You achieved speed, but you sacrificed **reliability**.

---

## Why cheap goroutines are expensive

Go makes concurrency easy because goroutines are lightweight. They start with a [**2KB stack**](https://go.dev/doc/faq#goroutines). While your CPU can manage 10,000 threads, your downstream dependencies cannot.

Unbounded `go func()` loops create a **Thundering Herd**. Instantaneous execution forces your system to simultaneously demand:

1.  **Memory:** 10,000 goroutines require 20MB of stack space just to initialize. This excludes the heap allocations required to process JSON or hold HTTP buffers.
2.  **File Descriptors:** Every outbound request requires a socket. Standard Linux environments cap file descriptors at 1,024 per process.
3.  **Connection Pools:** Your database pool is a finite resource. If it is capped at 50 connections, 9,950 goroutines will block while holding onto their allocated memory, causing massive **GC pressure**.

To build resilient systems, you must impose a **Speed Limit**.

---

## Use Semaphores for minimal refactoring

A [**Semaphore**](<https://en.wikipedia.org/wiki/Semaphore_(programming)>) restricts the number of threads accessing a shared resource. In Go, you can implement this pattern using a **buffered channel**.

A buffered channel blocks when full. By using it as a [**token bucket**](https://github.com/vikash-paf/goutils/tree/main/rate#tokenbucket), you control exactly how many goroutines execute their critical path at once. For production workloads requiring burst handling and precise refills, I use the [TokenBucket](https://pkg.go.dev/github.com/vikash-paf/goutils@v1.0.0/rate#TokenBucket) implementation from my `goutils` library.

### How to implement a channel-based Semaphore

1.  Create a buffered channel with a capacity equal to your limit.
2.  Push an empty struct into the channel before starting work (acquire token).
3.  Read from the channel when the work finishes (release token).

```go
func ProcessItemsWithSemaphore(items []Item, maxConcurrency int) {
    var wg sync.WaitGroup
    sem := make(chan struct{}, maxConcurrency) // The token bucket

    for _, item := range items {
        wg.Add(1)
        go func(i Item) {
            defer wg.Done()
            sem <- struct{}{}        // Acquire token (blocks if full)
            defer func() { <-sem }() // Release token
            process(i)
        }(item)
    }
    wg.Wait()
}
```

This pattern is ideal for quick scripts because it requires minimal code changes. However, it still spawns 10,000 goroutines. The loop completes instantly, leaving thousands of blocked goroutines parked in memory. For massive workloads, you need a more robust architecture.

---

## Use Worker Pools for sustained processing

While a Semaphore is a traffic light, a [**Worker Pool**](https://en.wikipedia.org/wiki/Thread_pool) is an assembly line. You spawn a fixed number of long-lived "Worker" goroutines that pull jobs from a shared channel.

### How to architect an assembly line

1.  Initialize a `jobs` channel.
2.  Spawn `N` workers that `range` over that channel.
3.  Feed items into the channel.
4.  Close the channel to signal workers to exit.

```go
func worker(id int, jobs <-chan Item, wg *sync.WaitGroup) {
    defer wg.Done()
    for item := range jobs {
        process(item)
    }
}

func ProcessItemsWithPool(items []Item, numWorkers int) {
    jobs := make(chan Item, len(items))
    var wg sync.WaitGroup

    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, &wg)
    }

    for _, item := range items {
        jobs <- item
    }
    close(jobs) // Signal shutdown
    wg.Wait()
}
```

Worker Pools decouple the **volume of work** from **resource consumption**. Whether processing 100 items or 100,000, your system only spawns `numWorkers` goroutines. Memory profiles remain flat, and GC pressure is minimized.

---

## Trade-offs and Costs

Engineering is a series of trade-offs. Bounding your concurrency introduces specific costs:

- **Latency vs. Stability:** Limiting concurrency increases the total execution time for a batch. You trade absolute speed for a predictable, non-collapsing system.
- **Complexity:** Worker Pools require more boilerplate than a simple `go func()`. You must manage channel lifecycles and [**context-based cancellation**](https://go.dev/blog/context) properly.
- **Deadlock Risk:** Improper channel management in pools can lead to deadlocks if workers are blocked while the feeder loop waits for capacity.

---

Optimizing for speed is the first phase of implementation. The second phase; and the most critical for production reliability, is identifying how safely to constrain that speed.

Unbounded concurrency is a bug that waits for a traffic spike to trigger. Establish a habit: every time you type `go func()`, identify its upper bound. If the loop is tied to user input or database rows, you need a speed limit.

Grab a **Semaphore** for scripts and a **Worker Pool** for production pipelines. Respect the hardware, and your database will remain stable.

