AlgoMaster Logo

Fan-In Pattern

High Priority14 min readUpdated June 6, 2026

Fan-in merges values from several input channels into one output channel, so a downstream consumer only has to read from a single place. It's the natural counterpart to fan-out: where fan-out spreads work across many goroutines, fan-in collects their results back into a single stream. This lesson covers the two canonical implementations, the closing rule that causes the most bugs, and a generic version you can drop into any project.

Why Fan-In Exists

Imagine an e-commerce dashboard that needs a live feed of orders coming in from three regional services: us-east, eu-west, and ap-south. Each service publishes orders on its own channel. The consumer doesn't care which region an order came from; it just wants one stream of orders to display, persist, or aggregate.

Without fan-in, the consumer has to either poll three channels in a select, or run three reader goroutines that each touch shared state. Both work for small N, but neither scales. With N regional feeds, you'd need an N-arm select (which you can't write dynamically in plain Go), or N readers all writing into the same downstream slice with a mutex.

Fan-in flips this around. You launch one forwarder goroutine per input channel, and each forwarder copies values onto a shared output channel. The consumer reads from that one output channel as if it were the only producer. The complexity of "how many inputs are there" moves into the merge function, and the consumer code stays trivial.

Three independent producers, one merged stream, one consumer. The producers don't know about each other, and the consumer doesn't know how many producers exist. That decoupling is the purpose of the pattern.

In Go terminology, fan-in is sometimes called "channel multiplexing" or "merge". The Go blog and standard library both use the name merge for the helper function. Whatever you call it, the shape is the same: N inputs of type <-chan T go in, one <-chan T comes out.

The WaitGroup Implementation

The first implementation works for any number of inputs (even one you don't know at compile time, like a slice of channels). It launches one goroutine per input to forward values onto the shared output channel, and one extra goroutine to close that channel once every forwarder is done.

Here's a fan-in over three regional order streams. Each region sends a few orders, then closes its channel.

The five orders arrive interleaved because the three forwarder goroutines are running concurrently. Each forwarder pulls values off its assigned input channel and pushes them onto out. The scheduler picks which forwarder runs when, so the interleaving depends on goroutine scheduling and isn't deterministic. If you need ordering, fan-in is the wrong tool; you'd want a single sorted pipeline or a priority queue downstream.

Step through merge line by line. The function takes a variadic ...<-chan string, so callers can pass any number of receive-only channels. It allocates one output channel out of the same type. The WaitGroup is sized to the number of inputs.

For each input channel, the function spawns a forwarder goroutine. The forwarder does exactly one thing: it ranges over the input channel until that channel closes, copying every value onto out. When the input closes, the for-range loop exits, the deferred wg.Done() fires, and the forwarder returns.

The closer goroutine is the part that's easy to miss. It blocks on wg.Wait() until every forwarder has reported done. Only then does it close out. This guarantees two things at once: every value from every input makes it onto out before the close, and the close happens exactly once.

Closing the Output Channel Correctly

The closing rule is where most fan-in bugs live. Three things must hold for the output channel to behave properly:

  1. Every value sent on any input channel must reach the output channel.
  2. The output channel must be closed exactly once.
  3. The close must happen after the last send, never before.

Violating any of these breaks consumers. If you close too early, sends panic. If you forget to close, the consumer's for-range blocks forever, and the consumer goroutine leaks. If you close twice, the second close panics.

The sync.WaitGroup + closer goroutine handles all three. Let's walk through why each part is necessary.

Why every input needs its own forwarder. A single forwarder reading from all inputs in a select would also work for fixed N, but it can't dynamically add or remove inputs, and it has to handle each input's close separately. One forwarder per input is simpler: when the input closes, the forwarder exits.

Why we wait on `wg.Wait()` before closing. The forwarders run concurrently. We don't know which finishes last, and we don't want to. The WaitGroup counts down to zero when the last forwarder calls wg.Done(), and the closer goroutine wakes up at that moment to close out. Any earlier close would risk a send on a closed channel.

Why the closer is its own goroutine. If merge blocked on wg.Wait() itself, the function couldn't return until all inputs drained. The caller wouldn't get the out channel back until everything was already done, which defeats the streaming purpose. The closer goroutine lets merge return immediately with a live out channel that closes naturally as inputs drain.

Here's what happens when one input panics or never closes. Suppose regionFeed for ap-south had a bug and never closed its channel. The forwarder for ap-south would block forever on its for-range loop, never calling wg.Done(). The closer goroutine would wait forever on wg.Wait(), and out would never close. The consumer's for-range over merged would never exit. That's a classic goroutine leak. Cancellation via a done channel is how you cut these leaks short, but for now the rule is: every producer must close its input channel when it finishes.

The forwarders converge on a single rendezvous point (wg.Wait), and the closer goroutine is the only thing that runs the close(out) line. That single-writer-of-close pattern is what makes the function safe.

What's wrong with this code?

This compiles and even produces correct output, but it's not really a fan-in. The goroutine drains a completely before touching b. If a never closes, b never gets read. If b produces values while a is still streaming, those values pile up in b's buffer (or block b's sender) instead of being forwarded.

Fix:

Two forwarders run concurrently, each draining its own input. Values from a and b arrive on out in whatever order they're produced, not in sequence.

Select-Based Merge for Fixed N

When you know the number of inputs at compile time and N is small, you can write the merge with a select instead of a WaitGroup. This avoids spawning forwarder goroutines, but it only works for a fixed, small N because you have to write each case by hand.

Here's a select-based merge for exactly two channels.

The single merge goroutine uses select to pull from whichever channel has a value ready. The two booleans (aOpen, bOpen) track which inputs are still live, and the loop exits when both are closed.

The trick on the close handling is setting a = nil (and b = nil) when the channel closes. A nil channel blocks forever in a select, which means the closed channel's case becomes dormant and select only considers the still-open channels. This is the cleanest way to "remove a case" from a select at runtime.

Compared to the WaitGroup version, the select-based merge uses one goroutine total (just the merge), instead of N+1. For two or three inputs, that's a real saving. The downside is that you can't write a select with a variable number of cases; if you needed a merge over N channels where N is determined at runtime, you'd have to use reflection (reflect.Select), which is significantly slower and harder to read. For variable N, the WaitGroup version is the better choice.

One goroutine instead of three. The cost is that select cases are compile-time constructs, so this shape only scales to whatever number of cases you're willing to write by hand. Two or three is fine. Ten is awkward. A hundred is impossible.

Generic Fan-In with Type Parameters

Both implementations above hard-code the channel element type. In real code you'll often want a merge that works for orders, log lines, metrics, anything. Go 1.18+ generics make this trivial.

The function signature FanIn[T any](inputs ...<-chan T) <-chan T reads cleanly: "merge any number of receive-only channels of T into one receive-only channel of T". Callers don't need to specify the type parameter because Go infers T from the input channels.

The body is identical to the non-generic merge from earlier, just with T in place of string. The compiler instantiates a fresh copy of FanIn for each concrete type you call it with. In practice you'd put this function in an internal helper package and reuse it across your codebase.

<-chan T in the signature is important. It accepts receive-only channels, which means you can pass any channel that's been narrowed to receive-only. A bidirectional chan T is implicitly convertible to <-chan T, so callers can pass either. The return type is <-chan T because we don't want callers sending values into the merged stream.

Backpressure and Buffering

Fan-in inherits the backpressure model of its output channel. If out is unbuffered (the default), every send on out blocks until the consumer reads. If out is buffered, sends block only when the buffer is full. Fast producers and a slow consumer mean the forwarders end up stuck on out <-, which propagates backpressure all the way back to the original producers.

This is usually what you want. If your consumer can process 100 orders per second and producers are generating 1000 per second, the unbuffered out makes the producers wait. Memory stays bounded. No goroutine accumulates a backlog.

It can also be a problem. If one input is much slower than another, the forwarder for the slow input takes longer per send, but it doesn't stop the fast forwarder. The fast forwarder keeps writing to out whenever the consumer is ready. The merged stream skews toward the fast producer. That's fine for "merge these streams" use cases, but it's wrong for "round-robin between these streams" where you want fair scheduling. Fan-in does not give fair scheduling. The Go scheduler picks which forwarder runs next based on goroutine-readiness, not on producer fairness.

To add a buffer, pass a capacity to make:

A buffer smooths out short bursts. If producers spike to 200 events at once and the consumer drains at a steady rate, a buffer of 50-100 absorbs the spike without making producers block. The trade-off is memory: each slot holds one value of type T. For small types this is cheap; for large structs it can matter.

Pick the buffer size based on what you know about producer variance. If producers are bursty but average throughput matches the consumer, a small buffer (10-100) helps. If producers are steady, an unbuffered channel works fine. If producers consistently outpace the consumer, no buffer fixes that. You need either a faster consumer, multiple consumers (fan-out from the merged stream), or a way to drop or compact values.

There's one more thing worth knowing. Because the consumer reads merged values one at a time, fan-in is fundamentally serial at the receive end. Multiple producers, single consumer. If you need parallelism on the consumer side too, you fan back out: the merged channel feeds N worker goroutines that each process values from out in parallel. That's a fan-in then fan-out pipeline.

Common Pitfalls

A few mistakes show up repeatedly when people write fan-in code. None of them are caught by the compiler.

Forgetting to close the output. The most common version of this is using a single goroutine that ranges over each input sequentially and forgets the close(out) at the end. The consumer's for-range blocks forever, the consumer goroutine leaks, and your program looks like it just stopped. Always close out exactly once, from a single goroutine that runs after all forwarders are done.

Closing the output too early. Putting close(out) inside one of the forwarders (because "this one looks like it'll finish last") races against the others. When that forwarder closes first, the others try to send on a closed channel and panic. Use a closer goroutine that waits on WaitGroup for Done from every forwarder.

Forgetting to close an input. This is on the producer side, not the merge function. If a producer goroutine never closes its channel, the corresponding forwarder in merge ranges forever, never calls wg.Done(), and the closer goroutine waits forever. The consumer hangs. The producer must always close its output channel when it's done sending. This is the producer's responsibility, not the merge's.

Sharing the same input channel across multiple forwarders. Passing the same <-chan T twice into FanIn(ch, ch) spawns two forwarders that both read from ch. Each value gets read by exactly one of them, but the close detection works twice (because closing a channel notifies every reader). The result is roughly correct, but it's a confusing pattern and serves no purpose. One forwarder per logical input.

Reading from `out` before `merge` returns. This isn't actually possible in Go because merge returns the channel before any values flow, but conceptually: the consumer should treat out as a normal channel. Read from it with for v := range out or <-out. Don't try to drain it inside merge itself.

The chain of dependencies is clear: producers close inputs, forwarders see the close and exit, the WaitGroup drops to zero, the closer runs, the consumer's loop ends. Break any link and the chain dangles.

Quiz

Fan-In Quiz

9 quizzes