Golang Fan-Out Fan-In Pattern: Goroutines, Channels, and Worker Pools

Learn the fan-out fan-in pattern in Go with goroutines and channels: distribute work, merge results, relate it to worker pools, handle errors and cancellation, preserve ordering when needed, and close channels safely.

Published

Updated

Read time 11 min read

Reviewed byDeepak Prasad

Golang Fan-Out Fan-In Pattern: Goroutines, Channels, and Worker Pools

This guide explains fan-out and fan-in in Go for developers who already know goroutines and channels. It separates the vocabulary from worker pools, walks through a small merge pipeline, then covers errors, cancellation, ordering, how many workers to run, and channel ownership—without treating every program as a concurrency exercise.

Tested with Go 1.24 on Linux.


Quick answer: fan-out distributes work, fan-in merges results

Fan-out means handing work to many goroutines so independent tasks run at the same time—often several workers reading from one jobs channel. Fan-in means taking those parallel outputs and combining them into one stream—often one goroutine merging many channels into one. Use the pattern when tasks are independent and you can merge or consume results as they finish; decide error and ordering rules before you wire the channels.


What is the fan-out fan-in pattern?

In Go, fan-out/fan-in is usually built from goroutines, channels, and coordination primitives such as sync.WaitGroup or errgroup (extension). One producer or stage creates work; many goroutines process it in parallel (fan-out); a merge or collector stage presents a single outbound stream to the rest of the program (fan-in). That shape is common for parallel file or API work, validation batches, and queue consumers.

Why it is useful

Throughput rises when work is embarrassingly parallel: each unit does not depend on others finishing first. The cost is design complexity—closing channels correctly, handling errors, and avoiding leaks when something stops early.


Fan-out vs fan-in

Piece Meaning Direction
Fan-out Split work across multiple goroutines One input stream → many workers
Fan-in Merge many streams into one Many workers → one output stream
Together Parallel processing with a single consumer One stream → many → one

Fan-out increases parallelism; fan-in brings results back together for one place to read, log, or aggregate.

Visual flow

text
+----------+
        | producer |
        +----+-----+
             |
             v
      (jobs channel)
             |
    +--------+---------+
    |        |         |
    v        v         v
 worker   worker     worker     <- fan-out (same code, parallel)
    |        |         |
    +--------+---------+
             |
      (merge / multiplex)
             |
             v
        +----+----+
        | consumer |                    <- fan-in (single outbound)
        +----------+

Where this fits in Go concurrency

Primitive Role in fan-out/fan-in
Goroutines Run workers and merge logic concurrently
Channels Carry jobs and results between stages
WaitGroup Wait until all merge readers finish before closing the output channel
context Cancel workers and stop sending when the consumer leaves or times out

For broader concurrency background, see concurrency in Go, WaitGroup, and context. Cooperative shutdown patterns overlap with stopping goroutines.


Basic fan-out fan-in flow

  1. Create the input work (slice, iterator, or generator goroutine sending on a channel).
  2. Start workers or parallel branches that read jobs and write results.
  3. Optionally multiplex many result channels with a merge that copies into one chan T.
  4. When all senders on a channel are done, close that channel so range exits.
  5. After all merge copies finish, close the shared output channel so the final consumer’s range exits.

The program below is a classic pipeline: two generator branches feed cube stages, and merge fan-in multiplexes both cubes streams into one channel. Run it with go run .; you should see six cubed integers (values 1³ through 6³). The line order can change because the merge interleaves goroutines—that is normal fan-in behavior.

go
package main

import (
	"fmt"
	"sync"
)

func generator(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

func cube(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			out <- n * n * n
		}
	}()
	return out
}

func merge(in ...<-chan int) <-chan int {
	var wg sync.WaitGroup
	out := make(chan int)
	output := func(c <-chan int) {
		defer wg.Done()
		for n := range c {
			out <- n
		}
	}
	wg.Add(len(in))
	for _, c := range in {
		go output(c)
	}
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

func main() {
	c1 := generator(1, 2, 3)
	c2 := generator(4, 5, 6)
	out := merge(cube(c1), cube(c2))
	for n := range out {
		fmt.Println(n)
	}
}
Output

Fan-out fan-in vs worker pool

Concept What it emphasizes
Fan-out Many goroutines doing work in parallel from shared or split input
Fan-in Collecting many result streams into one
Worker pool A fixed number of goroutines pulling from a queue; bounded fan-out
Pipeline Stages chained so each stage's output feeds the next

A worker pool is often how you implement fan-out with a cap on concurrency. Fan-in is how you merge or serialize results unless each worker writes to its own sink.

The terms overlap in real code. The important question is whether you need a hard limit on concurrent goroutines. If yes, use a worker-pool style fan-out. If not, the pattern may simply be a logical "split work, run in parallel, merge results" flow.

For example:

Scenario Better description
5 workers read jobs from one channel Worker pool / bounded fan-out
3 API calls run in parallel and results are merged Fan-out/fan-in
Files pass through read → parse → validate stages Pipeline
Multiple worker outputs are merged into one result channel Fan-in
Every message must go to every consumer Broadcast/pub-sub, not normal worker fan-out

One subtle point: in many Go examples, fan-out means workers share the input and each item is processed by one worker. It does not always mean every worker receives every item.


Real-world scenarios

Scenario Why fan-out/fan-in helps
Many files or blobs Each file can be processed independently
Multiple HTTP calls Latency hides behind concurrency when the API allows it
Record validation Rows can be checked in parallel; errors can be collected or fail fast
Images or media Resize, thumbnail, or transcode tasks can run independently
Health checks Independent probes can run in parallel
Queue jobs Workers dequeue jobs; a merger or single writer records outcomes
CPU-bound tasks Work can be spread across available CPU cores
I/O-bound tasks Waiting time can overlap across requests, files, or network calls

Use the pattern when work items are independent and you can clearly define how results and errors are combined.

Avoid using fan-out/fan-in only because concurrency looks attractive. It adds coordination overhead, so it is most useful when parallelism improves throughput, hides I/O latency, or keeps worker count bounded.


Handling errors

Channels of plain values do not carry errors. Common approaches:

Strategy When it fits
Fail-fast First hard error should cancel context and stop producers
Collect all errors Continue processing; aggregate (T, error) or append to an error slice
Partial success Return good results plus a list of failed IDs

Decide the policy before you fix the channel topology—fail-fast needs a way to unblock workers stuck on send (cancel or close paths), while “collect all” needs a bounded way to store errors without blocking forever.

A small pattern is to fan-in a Result value (or a (T, error) pair) on one channel so the consumer sees both data and errors in completion order:

go
type Result struct {
	Path string
	Size int64
	Err  error
}

// Workers send Result{Path: p, Err: err} on a shared or per-worker channel;
// merger copies into one stream; consumer checks res.Err per message.

For fail-fast across many goroutines, errgroup.Group is a common choice: the first non-nil error cancels the group context so sibling workers exit cleanly.


Cancellation and timeouts

If the consumer stops reading early but workers keep sending on a full or unbuffered result channel, sends can block forever—a goroutine leak. Tie shutdown to context.Context: workers select on ctx.Done() alongside real work; the producer stops enqueueing when the context is canceled; the merger exits and closes the output channel once workers finish.

A worker should also check cancellation before sending the result:

go
select {
case out <- result:
    // result delivered
case <-ctx.Done():
    return
}

This prevents the worker from getting stuck when the downstream consumer has already stopped reading.

The same idea applies when reading jobs:

go
select {
case job, ok := <-jobs:
    if !ok {
        return
    }
    // process job
case <-ctx.Done():
    return
}

The key rule is:

text
Every blocking send or receive should have a cancellation path when early exit is possible.

If you cancel, avoid sending on closed channels and avoid closing a channel more than once. When in doubt, use one goroutine that owns close on each channel end.


Ordering of results

Fan-out/fan-in usually does not preserve input order. Whichever worker finishes first sends its result first.

For example, assume the input order is:

text
job-1, job-2, job-3

But workers may finish like this:

text
job-2, job-3, job-1

That means the fan-in output may look different from the input order.

Input order Worker completion order Fan-in output
job-1 job-2 finishes first job-2
job-2 job-3 finishes second job-3
job-3 job-1 finishes last job-1

This is fine when order does not matter, such as:

  • health checks
  • independent file processing
  • parallel API calls
  • validation jobs
  • background queue work

If order matters, attach a sequence index to each job before fan-out:

go
type Job struct {
    Index int
    Value string
}

type Result struct {
    Index int
    Value string
    Err   error
}

Workers can process jobs in any order, but each result still carries the original index.

After collecting results, sort or reorder them by Index before returning them to the caller.

Short rule:

text
Fan-out improves concurrency, but result order follows completion order.
If input order matters, carry an index and reorder after fan-in.

Another option is to use a single worker when strict order is required and parallelism is not worth the extra coordination.


Choosing how many workers

Workload Rule of thumb
CPU-bound Often near runtime.NumCPU() or a small multiple; more goroutines than cores rarely speeds CPU work
I/O-bound Can use more workers because goroutines wait on I/O; still cap when APIs rate-limit or file handles matter
Unknown Start bounded, measure, then tune

Fan-out does not mean "unbounded goroutines per item". Large inputs need a pool, a semaphore, or chunked scheduling.

Example:

Scenario Better worker strategy
Resize 10,000 images bounded worker pool; CPU and disk can saturate
Call 500 HTTP endpoints more workers may help, but cap based on timeout, rate limit, and remote service capacity
Validate 1 million records fixed worker pool; avoid one goroutine per record
Process 20 small files simple fan-out may be enough
Unknown workload start with a small fixed pool, benchmark, then adjust

Short rule:

text
Use enough workers to keep the bottleneck busy, not enough to overload the system.

For CPU-heavy work, the bottleneck is usually CPU cores.
For I/O-heavy work, the bottleneck may be network latency, file descriptors, database limits, API rate limits, or memory.


Channel closing rules

Channel Who closes
Jobs / input The producer that will not send anymore
Per-worker output Usually the worker that owns that channel, after its loop ends
Shared merged output The merger, after WaitGroup.Wait or equivalent proves no more sends

A simple ownership flow looks like this:

text
producer owns close(jobs)

jobs channel
workers read jobs and send results
merger waits for all workers
merger owns close(results)

Only the sender should close a channel, and only when no more values will be sent.

This prevents:

  • panic from sending on a closed channel
  • result channel closing before all workers finish
  • consumers waiting forever
  • confusing range behavior

Example mistake:

text
worker-1 closes results
worker-2 still tries to send results
program panics

Better pattern:

text
workers only send results
one coordinator waits for all workers
coordinator closes the shared result channel

Short rule:

text
If multiple goroutines send to the same channel, none of those workers should close it individually.
Use one coordinator goroutine to close it after all senders are done.

Mistakes to avoid

Assuming output order matches input order—completion order wins unless you add sequence metadata.

Spawning one goroutine per row of a huge dataset with no bound—use a pool or semaphore.

Ignoring cancellation when a handler or CLI exits early—stop producers and unblock blocked sends.

Closing the merged result channel before all merge writers finish—consumers may panic or see incomplete data.

Using fan-out everywhere—sometimes a simple loop is clearer and fast enough.

Treating worker fan-out like broadcast—normal queue fan-out gives each job to one worker, not every worker.


Fan-out fan-in cheat sheet

Situation Approach
Independent tasks with one consumer Fan-out workers + fan-in merge
Must cap concurrency Worker pool or semaphore
Need one output stream Fan-in merge or single writer
Need original order Index jobs; sort after collection
First error should stop everything context.Cancel + fail-fast sends
Need every error Result type with error field or error slice
Consumer may leave early Context + stop sending; drain policy if needed
CPU-heavy inner loop Worker count near core count
I/O-heavy Higher parallelism possible—watch limits

Summary

Fan-out distributes work across goroutines; fan-in merges their outputs into one channel or consumer. Go expresses this with channels, small stage functions, and WaitGroup (or libraries like errgroup) so merges close the outbound stream only after all sources finish. Worker pools are a bounded style of fan-out. Plan for out-of-order completion, channel ownership, cancellation to avoid leaks, and explicit error policy—those choices matter as much as the sample pipeline.


References


Frequently Asked Questions

1. What does fan-out mean in Go?

Fan-out means splitting one stream of work across multiple goroutines so many units of work run in parallel, often by having several workers read from the same jobs channel.

2. What does fan-in mean in Go?

Fan-in means combining outputs from many goroutines or many channels into a single channel or consumer, for example a merge goroutine that multiplexes worker results.

3. Is a worker pool the same as fan-out fan-in?

A fixed worker pool is a common way to implement fan-out; fan-in is how you collect their results. The names emphasize distribution and merging; worker pool emphasizes a bounded number of goroutines.

4. Does fan-out fan-in preserve result order?

Usually not: results arrive in completion order. If you need input order, attach a sequence number and reorder after collection, or use a single worker.

5. Who should close channels in a fan-out fan-in pipeline?

Only the sender closes a channel when no more values will be sent; the merger closes the shared output after all workers finish sending, typically after WaitGroup.Wait.

6. How do I avoid goroutine leaks in fan-out fan-in?

Use context cancellation or a done channel so workers and senders stop when the consumer exits; avoid blocking forever on send to a channel nobody reads.
Antony Shikubu

Systems Integration Engineer

Highly skilled software developer with expertise in Python, Golang, and AWS cloud services.