go 131 lines · 3 tabs

Bounded Fan-Out With Worker Pool, errgroup, and Result Collection in Go

Shared by codesnips Jul 2026
3 tabs
package fetch

import (
	"context"
	"net/http"

	"golang.org/x/sync/errgroup"
)

type Result struct {
	URL    string
	Status int
	Body   []byte
}

func FetchAll(ctx context.Context, client *http.Client, urls []string, workers int) ([]Result, error) {
	g, ctx := errgroup.WithContext(ctx)

	jobs := make(chan string, workers)
	results := make(chan Result, len(urls))

	g.Go(func() error {
		defer close(jobs)
		for _, u := range urls {
			select {
			case jobs <- u:
			case <-ctx.Done():
				return ctx.Err()
			}
		}
		return nil
	})

	for i := 0; i < workers; i++ {
		g.Go(func() error {
			return worker(ctx, client, jobs, results)
		})
	}

	go func() {
		_ = g.Wait()
		close(results)
	}()

	collected := make([]Result, 0, len(urls))
	for r := range results {
		collected = append(collected, r)
	}

	if err := g.Wait(); err != nil {
		return nil, err
	}
	return collected, nil
}
3 files · go Explain with highlit

This snippet shows the idiomatic Go pattern for fanning work out to a fixed pool of goroutines and collecting typed results, without leaking goroutines or letting a slow producer overwhelm the system. The core idea is that unbounded fan-out (spawning one goroutine per input) is easy but dangerous: with large inputs it can exhaust memory or hammer a downstream service. A bounded worker pool caps concurrency to a known number of workers while still letting context cancellation short-circuit the whole batch on the first failure.

In pool.go, FetchAll is the public entry point. It creates a buffered jobs channel and a buffered results channel, then uses golang.org/x/sync/errgroup with errgroup.WithContext so that the first worker to return an error cancels every other worker through the derived ctx. A dedicated feeder goroutine pushes each URL onto jobs and closes the channel when done, but it uses a select on ctx.Done() so it stops early rather than blocking forever if the group has already been cancelled. Exactly workers goroutines are launched with g.Go, and each runs worker, which ranges over jobs until the channel is drained.

The subtle part is result collection. Because results is written by many goroutines, the code must not close it while writers are still active. A separate goroutine waits on g.Wait() and only then closes results, so the for r := range results loop in FetchAll terminates cleanly. g.Wait() also surfaces the first error, which is checked after the drain. Note that results is buffered to len(urls) so workers never block on a full channel even if the collector is momentarily behind.

In worker.go, worker calls fetch per job and forwards a Result onto results, again guarding the send with a select on ctx.Done() to avoid a goroutine leak on cancellation. Returning a non-nil error from any fetch triggers the group-wide cancel.

The main trade-off is throughput versus resource safety: a larger workers count increases parallelism but also load on the target. This pattern is the right reach whenever bounded concurrency, fail-fast semantics, and ordered-independent result gathering are all needed at once.


Related snips

Share this code

Here's the card — post it anywhere.

Bounded Fan-Out With Worker Pool, errgroup, and Result Collection in Go — share card
Link copied