Skip to content
Tech7 Jan 2026

Concurrency in Go: 3 patterns for efficient, safe code

Worker Pool, Broadcast Stop Signal, Fan-In. The 3 Go concurrency patterns that make your code efficient, safe and maintainable.

By Paolo Galeotti

Go is famous for its built-in concurrency features such as goroutines and channels. But using them effectively takes more than a plain go func(): it takes structure. In this article, we will explore three practical concurrency patterns that will help you build reliable, maintainable concurrent programs in Go.

Table of contents:

  • Worker Pool: limiting the number of goroutines that process jobs
  • Broadcast Stop Signal: stopping multiple goroutines cleanly
  • Fan-In: combining output from multiple goroutines

Each pattern includes code examples, usage guidelines and real-world use cases.

1. Worker Pool: controlled concurrency

Imagine you have a list of tasks to run, perhaps resizing images, scraping URLs or sending emails. You want to process many of them in parallel, but without spawning hundreds of goroutines (which could exhaust system resources or overload external services).

A Worker Pool solves this problem by starting a fixed number of goroutines (the "workers") and feeding them tasks through a channel. Each worker takes a task, processes it, then waits for the next one.

This way, you keep concurrency under control and your program becomes more predictable and efficient.

🔧 Code example

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job)
        time.Sleep(time.Second) // Simulated work
        results <- job * 2
    }
}

func main() {
    const numJobs = 5
    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)

    // Start 3 workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send the jobs
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    // Collect the results
    for a := 1; a <= numJobs; a++ {
        fmt.Println("Result:", <-results)
    }
}

✅ When to use it

  • You have many small, independent tasks
  • You want to limit concurrency (to avoid overloading a server)
  • Your workers can be reused and do not need unique persistent state

❌ When to avoid it

  • Tasks are infrequent: creating ad-hoc goroutines may be simpler
  • Each worker needs its own long-lived state (such as a DB connection)
  • You need instant, unbounded parallelism (use goroutines directly, with care)

💡 Real-world use cases

  • Sending emails in batches
  • API calls with rate limits
  • Processing jobs from a queue

2. Broadcast Stop Signal: coordinated shutdown

Sometimes you have several goroutines running in the background, perhaps listening for events or processing jobs. When your app shuts down (or an error occurs), you want to tell all of them to stop.

This pattern is simple and avoids common mistakes such as using context.Context in long-lived structs (which is considered an anti-pattern).

🔧 Code example

type Worker struct {
    id     int
    stopCh <-chan struct{}
}

func (w Worker) Start() {
    go func() {
        for {
            select {
            case <-w.stopCh:
                fmt.Printf("Worker %d stopping\n", w.id)
                return
            default:
                fmt.Printf("Worker %d is working...\n", w.id)
                time.Sleep(500 * time.Millisecond)
            }
        }
    }()
}

func main() {
    stop := make(chan struct{})
    workers := []Worker{
        {1, stop},
        {2, stop},
    }

    for _, w := range workers {
        w.Start()
    }

    time.Sleep(2 * time.Second)
    close(stop) // Broadcast stop signal
    time.Sleep(1 * time.Second) // Wait for the workers to stop
}

This works because in Go, closing a channel signals all of its consumers. It cannot be done simply by sending data on the channel, because that would be consumed only by the first available goroutine and not by the others.

✅ When to use it

  • You have long-running goroutines (such as listeners or pollers)
  • You want a centralised shutdown signal
  • You do not need per-operation timeouts or error propagation

❌ When to avoid it

  • For request-scoped logic: prefer context.Context
  • When you use timeouts, deadlines or parent-child cancellation chains
  • When each goroutine manages its own lifecycle separately

⚠️ Why not use context.Context here?

Using context.Context as a field in a long-lived struct is an anti-pattern because:

  • Contexts are meant to be passed around, not stored
  • They represent the lifetime of an operation, not of a component
  • Reusing a cancelled context leads to subtle bugs and undefined behaviour

Instead, use a simple channel such as stopCh to signal the component's shutdown: it is idiomatic and safer.

💡 Real-world use cases

  • Gracefully stopping background workers on app shutdown
  • Stopping consumers from a message broker (e.g. Kafka)
  • Shutting down TCP/UDP listeners on SIGTERM

3. Fan-In: stream aggregation

Sometimes you have several goroutines producing data independently (scrapers, sensors, file readers, etc.), and you want to combine their outputs into a single stream.

The Fan-In pattern merges multiple input channels into one output channel. This makes it easy to centralise processing logic while keeping the producers separate and independent.

🔧 Code example

func producer(id int, out chan<- string) {
    for i := 0; i < 3; i++ {
        out <- fmt.Sprintf("Producer %d: item %d", id, i)
        time.Sleep(time.Duration(id) * 200 * time.Millisecond)
    }
    close(out)
}

func fanIn(channels ...<-chan string) <-chan string {
    out := make(chan string)
    for _, ch := range channels {
        go func(c <-chan string) {
            for val := range c {
                out <- val
            }
        }(ch)
    }
    return out
}

func main() {
    a := make(chan string)
    b := make(chan string)

    go producer(1, a)
    go producer(2, b)

    merged := fanIn(a, b)

    for i := 0; i < 6; i++ {
        fmt.Println("Received:", <-merged)
    }
}

✅ When to use it

  • Multiple goroutines produce values concurrently
  • You want to merge their outputs to process them in one place
  • Each producer can work independently

❌ When to avoid it

  • If the order of the output matters (fan-in does not preserve it)
  • If you need to track which source each message comes from
  • If the producers are not running concurrently: a simple merge may be enough

💡 Real-world use cases

  • Merging logs from multiple microservices
  • Combining results from parallel HTTP scrapers
  • Aggregating events from different input sources

Conclusion

Each of these concurrency patterns helps structure your Go code for clarity, safety and performance.

By understanding when to use these patterns, and when not to, you can build concurrent systems in Go that are fast, safe and maintainable.

This article was written by Paolo Galeotti, Backend Developer at Quinck. Read more articles on our blog for insights on software development and technological innovation.

RELATED SERVICECustom software

Did we make you curious?

If you have a problem, an idea or just a curiosity: let us talk. Half an hour, no strings attached.

Get in touch

Related articles