AlgoMaster Logo

Common Goroutine Patterns

High Priority16 min readUpdated June 6, 2026

This chapter is a tour of concurrency patterns you can build with just go and sync.WaitGroup. The patterns here are deliberately simple, and the constraint is what makes them composable.

What You Can Do Without Channels

The five patterns in this chapter cover most of the day-to-day concurrent work in Go services.

PatternWhen to UseTools Needed
Fan-outIndependent work over N itemsgo + WaitGroup
Fan-in (aggregation)Collecting results from many goroutinesgo + WaitGroup + slice indices
Bounded parallelismLimit concurrent workersgo + WaitGroup + pre-partitioning
Per-request goroutineOne goroutine per incoming unit of workgo + WaitGroup
Fire-and-forgetBackground work you don't wait ongo + defer recover()

Anything that needs goroutines to talk to each other while they run, like a pipeline streaming items through multiple stages, fundamentally wants channels. Forcing those shapes onto a WaitGroup-only world produces awkward code with shared slices, mutexes, and polling loops.

Each pattern below follows the same structure: when to use it, a runnable example with an e-commerce twist, a diagram of the flow, and a "watch out for" subsection.

Pattern 1: Fan-Out

Fan-out means spawning N goroutines to do independent work in parallel. The work on item i doesn't depend on item j, so spawning one goroutine per item lets them all run at once, and you wait for the group to finish.

The classic e-commerce case is checking product availability across multiple suppliers. Each supplier check is an independent network call. Sequentially the total time is the sum of all calls. Fanning out makes the total roughly equal to the slowest single call.

The key line is Checked 5 suppliers in 200ms. Sequentially this would have taken 1 second. With fan-out it takes roughly the time of the slowest single call.

The stocks slice is pre-allocated, and each goroutine writes to its own unique index. No two goroutines ever touch the same memory location, which is why no mutex is needed. The slice header isn't being mutated; only distinct slots inside the backing array are being filled in.

The closure receives idx and supplierID as arguments rather than capturing loop variables. Go 1.22+ changed loop variable semantics so this would be safe without the argument copy, but passing them explicitly is clearer and works across all Go versions.

One coordinator spawns five workers, which run in parallel. The coordinator blocks on wg.Wait() until all five report done. Only then does it read the results.

What to Watch Out For

The work must actually be independent. If two goroutines need to mutate the same shared state (a running total, a shared map, an append target), you've left fan-out territory. Give each goroutine its own slot, or use a mutex.

N can be too big. Fanning out over 5 suppliers is fine. Fanning out over 50,000 items spawns 50,000 goroutines, each holding a network connection and consuming memory. When N is big or variable, use the bounded parallelism pattern below.

A panic in one goroutine kills the whole program. If pretendCheckSupplier panics, the runtime doesn't catch it and the program crashes, taking the other in-flight goroutines with it. The fix is defer recover() inside each goroutine, shown in the fire-and-forget pattern.

Pattern 2: Fan-In (Aggregation)

Fan-in is the opposite of fan-out: multiple goroutines produce results, and one place collects them. In channel-based code this usually means merging several incoming channels into one. Without channels, the equivalent is pre-allocating a result slice and having each goroutine write to its own slot.

The fan-out example above already did this. Each goroutine wrote to stocks[idx], and after wg.Wait() returned, main read all the slots. That's a fan-in by index. When goroutines need to produce results, pre-allocate the container, give each goroutine an owned slot, and read everything after the wait.

A slightly bigger e-commerce example: computing total revenue for the day, sharded across regions. Each region's total is independent, and the final number is the sum.

After wg.Wait() returns, every slot in results has been written exactly once. Main reads them in order and sums them. The aggregation happens entirely in the main goroutine after the workers are done. No locking, no contention.

An alternative shape uses sync.Mutex with a shared slice that workers append to. The short version is that workers grab the mutex, append, then release. That's more flexible when you don't know how many results each worker will produce, but it's slower and easier to get wrong. The per-index slice approach is preferred when you can pre-size the output.

Workers write to distinct slice positions. Main reads only after the wait completes. That separation in time is what makes the pattern correct without any locks.

What to Watch Out For

The reader must wait. Reading results before wg.Wait() returns is a data race. The slice slot is written by one goroutine and read by another with no happens-before relationship. Always read after the wait, never during.

Pre-allocate, don't append. Writing results = append(results, value) inside each goroutine races on the slice header itself, plus risks writes to the same backing array slot. Pre-allocating with make([]T, n) and assigning by index sidesteps both.

Index ownership must be unique. Double-check each goroutine gets a distinct index. The classic bug is closing over the loop variable (pre-Go 1.22) so every goroutine writes to the same final index.

Pattern 3: Bounded Parallelism

Fan-out is great when N is small. When N is large, you need a cap. Processing 10,000 orders with one goroutine each means 10,000 simultaneous database connections, which your database won't appreciate. Bounded parallelism says: use exactly K workers, regardless of how many items there are.

In channel-based Go, the textbook answer is a chan struct{} semaphore. Without channels, the cleanest approach is pre-partitioning: split the work into K chunks up front, spawn K goroutines, and give each one chunk to process sequentially.

Here's a batch order processor with 4 workers and 12 orders.

Twelve orders at 50ms each is 600ms sequentially. With 4 workers running in parallel, each worker handles 3 orders, totaling 150ms. The result is the same as fanning out 12 goroutines, but only 4 goroutines exist at any time.

Worker w handles indices w, w+4, w+8. So worker 0 gets indices 0, 4, 8 (orders 101, 105, 109). Worker 1 gets indices 1, 5, 9, and so on. This "stride" partition is one of the simplest ways to split work evenly. An alternative is "block" partitioning where worker 0 gets indices 0-2, worker 1 gets 3-5, etc. Stride is slightly more robust when individual items have variable cost, since slow items are spread across workers instead of concentrated in one block.

Four workers, each owning a strided subset, all writing to non-overlapping slots. Same fan-out shape, but with the goroutine count fixed at a small constant instead of one per item.

What to Watch Out For

Picking the right K. For CPU-bound work, K = runtime.GOMAXPROCS(0) (typically the CPU core count) is the sweet spot. For network or database work, K can be much higher (50-100 is common) because goroutines spend most of their time waiting. Benchmarking is the only way to know for sure.

Imbalanced work. Stride partitioning assumes each item takes roughly the same time. If item 100 takes 10 seconds and the others take 10ms, one worker stays busy long after the others finish. For variable per-item cost, a channel-based work queue fits better because workers grab the next item dynamically instead of being assigned a fixed list.

No coordinated cancellation. If one chunk hits a fatal error, the other workers keep grinding. There's no built-in stop signal. Coordinated cancellation is what context and errgroup handle. Pre-partitioning is best for independent, idempotent work where partial completion is acceptable.

Pattern 4: Per-Request Goroutine

Per-request goroutine means spawning one goroutine for each unit of work that arrives, processing it independently, and waiting for the whole batch to finish. The "request" can be an HTTP request, an order, a row from a CSV, anything that's a discrete unit of work.

The difference from fan-out is mostly framing. Fan-out parallelizes sub-tasks of one computation. Per-request handles distinct business actions (place an order, send an email, generate a report) where each one succeeds or fails on its own.

In an e-commerce system this comes up when handling a batch of orders that just arrived. Each order is independent. You want them processing in parallel and you want to know when the batch is done.

Each order gets its own goroutine. Each goroutine writes its result to its slot in results. After the wait, main reports the outcome. One bad order doesn't stop the others; it just shows up in the final report as rejected.

This pattern backs many HTTP servers internally. The Go net/http package spawns a goroutine per incoming connection, so you don't write go yourself. The shape is the same though: one goroutine per logical request, owning that request's lifetime.

Mechanically this is identical to fan-out. The difference is that each goroutine does meaningful business work, not just a sub-task of a bigger computation.

What to Watch Out For

Capture the right per-request data. Each goroutine needs its own copy of the request. Passing order as a function argument (as we did) gives each goroutine an independent copy. Closing over the loop variable directly in older Go versions means every goroutine sees the same final order.

One request shouldn't crash the batch. A panic in one goroutine takes the program down. For real per-request handling, wrap the body in panic recovery (see the fire-and-forget pattern below). The Go HTTP server does this automatically; if you're rolling your own, you're responsible for it.

The batch can grow unbounded. Small batches (a few hundred) are fine. Huge batches (50,000+) turn this into the unbounded fan-out problem. Switch to bounded parallelism for those cases.

Pattern 5: Fire-and-Forget (With Caveats)

Fire-and-forget spawns a goroutine for background work the caller doesn't wait on. The classic case is sending a metric, logging an event, or warming a cache after an operation completes. You don't want the user-facing request slowed down by these side effects.

The pattern is useful and dangerous in equal measure. The naive shape:

That one line has three problems. The goroutine might leak if sendAnalytics blocks forever. A panic inside it crashes the entire program. If your process exits before the goroutine finishes, the work is lost silently. All three show up in production.

A safer version wraps the work in a recoverable function. After placing an order, fire off an analytics event in the background. The order response goes back to the user immediately; the analytics call happens on its own time.

placeOrder returns immediately after logging "placed", and the analytics call happens in the background. The panic in sendAnalytics(1003) is caught by the recover() in safeGo, logged, and the program keeps running. Without safeGo, that panic would take down the whole process. The trailing WaitGroup is a demo artifact to keep main alive long enough to see the background output, not part of the pattern.

The dotted arrow from safeGo to sendAnalytics is the point. The caller doesn't wait. The function returns as soon as the goroutine is launched, not when the analytics call finishes.

What to Watch Out For

Panics kill the program without recovery. An unrecovered panic in any goroutine propagates up and the runtime calls os.Exit. The safe pattern is always: defer recover() inside the goroutine, log the panic, return cleanly.

No error return. The caller never sees errors from a fire-and-forget goroutine. If the analytics call fails 100% of the time, you'll only find out from logs or monitoring. Treat fire-and-forget as best-effort work whose failure must not break the main flow.

Lost work on shutdown. If your process exits while background goroutines are running, those goroutines die mid-flight with no automatic flush. For work that must complete, either wait for it (defeating the "forget" part) or persist the request to durable storage.

Leak risk if the work never returns. Wrapping a function blocked on a network call with no timeout creates a leak. Every long-running background goroutine needs a timeout or cancellation mechanism.

Anti-Patterns

Some shapes look like goroutine patterns but aren't. Recognizing them helps you avoid writing concurrent code where sequential code is simpler, safer, and faster.

Spawning a Goroutine You Immediately Wait On

There's no parallelism here. Main spawns one goroutine, blocks on the wait, then continues. Total time equals a direct call to computeSomething() plus the overhead of goroutine creation and wait group bookkeeping. If you're going to wait on it immediately, just call the function.

Unbounded Goroutines for Unbounded Work

If allOrdersEverPlaced has 10 million entries, this spawns 10 million goroutines. Each allocates a stack (around 2KB minimum), each wants scheduler time, each might open a database connection. The system runs out of memory or file descriptors and crashes. The fix is bounded parallelism: pick a worker count, partition the work, let the workers grind through it.

Background Goroutine With No Stop Signal

This goroutine runs forever, with no way to stop it from outside. In tests, in graceful shutdown, in any code that imports this package, it just keeps running. The fix is to give every long-running goroutine a stop mechanism via context.Context or a signal value.

Sequential Work Wrapped in Goroutines

A loop that processes items one at a time isn't automatically faster when you wrap each iteration in go. If the work is CPU-bound and you have one CPU, you've added scheduler overhead without parallelism. If the work touches shared state, you've added races. Goroutines speed things up when work is genuinely parallel. They slow things down when work is sequential or dependent.

Putting It Together

Choosing between these patterns comes down to a few questions about the work.

QuestionPattern
Small N of independent items?Fan-out
Need results back from each?Fan-in via per-index slice writes
Large or unbounded N?Bounded parallelism
Each item is a distinct business action?Per-request goroutine
Caller doesn't need to wait?Fire-and-forget with recovery

In real code you often mix patterns. An HTTP server uses per-request goroutines for incoming requests, and each request might fan-out to multiple databases, fire-and-forget an analytics call, and use bounded parallelism for any batch work. Channel-based patterns handle the cases this chapter can't: streaming work through stages, dynamically balancing work, propagating errors across a goroutine tree, and coordinating cancellation.

Quiz

Goroutine Patterns Quiz

10 quizzes