golangtutorialAll tutorials

Go Worker Pool Pattern: Bounded Concurrency at Scale

Build a Go worker pool with tested code: bounded concurrency, error propagation, context cancellation, correct channel closing, and how to size the pool.

Concurrency · Lesson 5Saved in this browser. No account required.

If you have spawned a goroutine per job and watched memory climb or a downstream service start returning 429s, this is the fix. You will build a worker pool from a jobs channel and N workers up to a production batch processor with error collection, context cancellation, and correct shutdown. Every example was run on Go 1.24.7. Works with Go 1.21+.

This guide assumes you can already start a goroutine and read a channel. If either is shaky, the goroutines concurrency guide and Go channels explained cover the mechanics, and the complete Go tutorial covers the rest of the language.

Why unbounded goroutines break in production

The reason to bound concurrency is that go is too cheap to be safe. Starting a goroutine costs about a microsecond and 2 KB, so nothing in the language stops you from writing this when a batch of 50,000 orders arrives:

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func processOrder(id int, gate chan struct{}, wg *sync.WaitGroup) {
	defer wg.Done()
	<-gate                            // all workers park here until main releases them
	time.Sleep(50 * time.Millisecond) // simulate a slow DB write or downstream API call
	_ = id
}

func main() {
	const orders = 50_000
	var wg sync.WaitGroup
	gate := make(chan struct{})

	for id := 1; id <= orders; id++ {
		wg.Add(1)
		go processOrder(id, gate, &wg) // one goroutine per order, nothing bounds this
	}

	fmt.Printf("orders queued:   %d\n", orders)
	fmt.Printf("live goroutines: %d\n", runtime.NumGoroutine())

	close(gate) // release them all at once: 50,000 downstream calls fire together
	wg.Wait()
	fmt.Println("done")
}

Output:

orders queued:   50000
live goroutines: 50001

Fifty thousand goroutines, all live, all about to hit the same database at the same instant. The goroutines themselves are not the problem; the resources behind them are. Each one wants a database connection from a pool that has 20, or a socket, or a slot under a downstream API’s rate limit. Fifty thousand concurrent calls do one of three things: exhaust your connection pool and block, tip the downstream service into overload, or trip its rate limiter so every call fails. And because you launched everything at once, there is no backpressure: the producer never slows down to match what the consumer can absorb.

A worker pool fixes all three. You run a fixed number of workers, say 10, and no matter how many jobs arrive, only 10 are ever in flight. The downstream sees a steady 10 concurrent calls, the connection pool is never oversubscribed, and the jobs channel provides natural backpressure because feeding it blocks once the workers are busy.

The core worker pool: N workers ranging over a jobs channel

A worker pool has four parts: a jobs channel to send work in, N worker goroutines that pull from it, a results channel to send answers back, and a sync.WaitGroup so you know when the workers are done.

Build it in three pieces. First, the work itself, any function:

func processOrder(id int) string {
	time.Sleep(100 * time.Millisecond) // simulate work: a DB write, an API call
	return fmt.Sprintf("order-%d charged", id)
}

Second, the worker. It ranges over the jobs channel, which loops until the channel is closed and drained, and sends each answer to results. The defer wg.Done() fires when the worker returns:

func worker(jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()
	for id := range jobs { // loops until jobs is closed and drained
		results <- processOrder(id)
	}
}

The directional types in the signature (<-chan receive-only, chan<- send-only) let the compiler enforce that a worker cannot accidentally send a job or read a result. Third, wire it together: start five workers, feed 30 orders, close jobs, collect 30 results.

package main

import (
	"fmt"
	"sync"
	"time"
)

func processOrder(id int) string {
	time.Sleep(100 * time.Millisecond) // simulate work: a DB write, an API call
	return fmt.Sprintf("order-%d charged", id)
}

func worker(jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()
	for id := range jobs {
		results <- processOrder(id)
	}
}

func main() {
	const (
		orderCount  = 30
		workerCount = 5
	)
	jobs := make(chan int, orderCount)
	results := make(chan string, orderCount)

	start := time.Now()

	var wg sync.WaitGroup
	for w := 1; w <= workerCount; w++ {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}

	for id := 1; id <= orderCount; id++ {
		jobs <- id
	}
	close(jobs) // tell every worker: no more work coming

	for i := 0; i < orderCount; i++ {
		<-results
	}

	wg.Wait()
	elapsed := time.Since(start)
	fmt.Printf("%d orders, %d workers\n", orderCount, workerCount)
	fmt.Printf("elapsed:    %v\n", elapsed.Round(time.Millisecond))
	fmt.Printf("throughput: %.0f orders/sec\n", float64(orderCount)/elapsed.Seconds())
}

Output:

30 orders, 5 workers
elapsed:    602ms
throughput: 50 orders/sec

Thirty orders at 100 ms each is 3 seconds of work done in 602 ms, because five run at a time: 30 jobs in 6 batches of 100 ms. Sequentially it would take 3 seconds. The single knob workerCount sets exactly how much concurrency you allow, which is the entire point.

The results channel is buffered to orderCount here so workers never block on a send. That works for a fixed, known batch. It falls apart the moment you want to stream results as they finish or you do not know the count in advance, which is where closing comes in.

Closing channels in the right order so workers exit and the collector stops

The version above collects a fixed number of results, then waits. Real pipelines usually want to range over results and let the loop end on its own when everything is done. That requires closing the results channel, and closing it at the wrong time is the classic worker pool bug: close too early and a worker sends on a closed channel (panic), close from a worker and a second worker closes it again (panic), never close and the collector’s range blocks forever (deadlock).

The rule is: whoever sends must not close, and the close must happen after every sender has stopped. Concretely, use a separate goroutine that waits on the WaitGroup and then closes results.

package main

import (
	"fmt"
	"sync"
	"time"
)

func processOrder(id int) string {
	time.Sleep(100 * time.Millisecond)
	return fmt.Sprintf("order-%d charged", id)
}

func worker(jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()
	for id := range jobs {
		results <- processOrder(id)
	}
}

func main() {
	const orderCount = 12
	jobs := make(chan int)
	results := make(chan string)

	var wg sync.WaitGroup
	for w := 1; w <= 4; w++ {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}

	// Feed jobs from their own goroutine so main can range results concurrently.
	go func() {
		for id := 1; id <= orderCount; id++ {
			jobs <- id
		}
		close(jobs) // 1. closing jobs lets each worker's range end
	}()

	// Close results only after every worker has returned.
	go func() {
		wg.Wait()      // 2. wait for all workers to finish sending
		close(results) // 3. now safe to close: no worker will send again
	}()

	count := 0
	for res := range results { // 4. ends cleanly when results is closed and drained
		count++
		_ = res
	}
	fmt.Printf("collected %d results, channel closed cleanly\n", count)
}

Output (run with -race, no warnings):

collected 12 results, channel closed cleanly

Trace the ordering. Closing jobs ends each worker’s range, so every worker returns and calls wg.Done(). Once all four have returned, wg.Wait() unblocks in the closer goroutine, which closes results. That lets the range results in main drain the last buffered values and exit. No send races the close, because the close is downstream of every worker finishing. This close-after-Wait shape is the backbone of every pool below.

Propagating errors from workers with a result struct

Real jobs fail, and a results chan string has nowhere to put an error. The fix is to make the result a struct that carries a value or an error, the same shape a function’s (T, error) return has. Workers never panic or log on their own; they package the outcome and send it, and the collector decides what to do.

package main

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

type Result struct {
	OrderID int
	Charge  string
	Err     error
}

func processOrder(id int) (string, error) {
	time.Sleep(50 * time.Millisecond)
	if id%7 == 0 { // pretend the payment gateway rejects every 7th order
		return "", fmt.Errorf("order %d: payment declined", id)
	}
	return fmt.Sprintf("order-%d charged", id), nil
}

func worker(jobs <-chan int, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for id := range jobs {
		charge, err := processOrder(id)
		results <- Result{OrderID: id, Charge: charge, Err: err}
	}
}

func main() {
	const orderCount = 21
	jobs := make(chan int)
	results := make(chan Result)

	var wg sync.WaitGroup
	for w := 1; w <= 4; w++ {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}
	go func() {
		for id := 1; id <= orderCount; id++ {
			jobs <- id
		}
		close(jobs)
	}()
	go func() {
		wg.Wait()
		close(results)
	}()

	var failures []error
	ok := 0
	for res := range results {
		if res.Err != nil {
			failures = append(failures, res.Err)
			continue
		}
		ok++
	}

	fmt.Printf("succeeded: %d\n", ok)
	fmt.Printf("failed:    %d\n", len(failures))
	for _, err := range failures {
		fmt.Println("  -", err)
	}
	if len(failures) > 0 {
		fmt.Println("joined:", errors.Join(failures...) != nil)
	}
}

Output (the failed lines can arrive in any order across runs):

succeeded: 18
failed:    3
  - order 7: payment declined
  - order 14: payment declined
  - order 21: payment declined
joined: true

The collector keeps every failure instead of stopping at the first, which is what a batch job wants: process all 21 orders, then report the 3 that failed. errors.Join (Go 1.20+) bundles them into one error you can return upward. If you need richer handling, wrap with %w and inspect with errors.Is, covered in Go error handling. This value-or-error result is the single most important upgrade over the tutorials that stop at chan string.

Stopping the whole pool early with context cancellation

Collecting every error suits a batch that must finish. An interactive request is different: if one job fails or the client’s deadline passes, you want the whole pool to stop now, not grind through 900 more jobs whose result nobody will read. That is what context.Context is for. The workers watch ctx.Done(), the feeder stops feeding, and the first error triggers cancel().

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

func processOrder(ctx context.Context, id int) error {
	select {
	case <-time.After(100 * time.Millisecond): // simulate the work
		if id == 8 {
			return fmt.Errorf("order %d: fraud check failed", id)
		}
		return nil
	case <-ctx.Done(): // cancelled: stop immediately
		return ctx.Err()
	}
}

func worker(ctx context.Context, jobs <-chan int, errs chan<- error, wg *sync.WaitGroup) {
	defer wg.Done()
	for id := range jobs {
		if ctx.Err() != nil { // stop pulling new jobs once cancelled
			return
		}
		if err := processOrder(ctx, id); err != nil {
			errs <- err
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	jobs := make(chan int)
	errs := make(chan error, 1)

	var wg sync.WaitGroup
	for w := 1; w <= 4; w++ {
		wg.Add(1)
		go worker(ctx, jobs, errs, &wg)
	}

	go func() {
		for id := 1; id <= 100; id++ {
			select {
			case jobs <- id:
			case <-ctx.Done(): // stop feeding once someone cancelled
			}
		}
		close(jobs)
	}()

	go func() {
		wg.Wait()
		close(errs)
	}()

	start := time.Now()
	var firstErr error
	for err := range errs {
		if firstErr == nil {
			firstErr = err
			cancel() // signal every worker and the feeder to stop
		}
	}

	fmt.Printf("stopped after %v\n", time.Since(start).Round(50*time.Millisecond))
	fmt.Println("first error:", firstErr)
	fmt.Println("was cancelled:", errors.Is(ctx.Err(), context.Canceled))
}

Output:

stopped after 200ms
first error: order 8: fraud check failed
was cancelled: true

One hundred orders through four workers would take 100 / 4 batches at 100 ms, about 2.5 seconds. It stopped in 200 ms because order 8 failed in the second batch, cancel() fired, and every worker saw ctx.Err() != nil and returned instead of pulling more jobs. defer cancel() on the context is not optional: a context.WithCancel or WithTimeout that is never cancelled leaks the context’s internal goroutine. The two escape hatches every worker needs are the ctx.Done() case inside processOrder (to abort a call already in progress) and the ctx.Err() check in the loop (to stop taking new work).

Choosing the worker count: CPU-bound versus I/O-bound

The most common question is “how many workers?” and the answer depends entirely on what a job does with its time. A CPU-bound job (parsing, hashing, compression) spends its time computing, so more workers than cores just adds scheduling overhead: the ceiling is GOMAXPROCS, which defaults to the core count. An I/O-bound job (a database query, an HTTP call) spends most of its time waiting, so a waiting worker holds a core hostage for nothing, and you want many more workers than cores. Here is the curve, measured on a 2-core machine:

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func ioBound()  { time.Sleep(20 * time.Millisecond) } // spends its time waiting
func cpuBound() {                                      // keeps a core busy
	sum := 0.0
	for i := 0; i < 4_000_000; i++ {
		sum += float64(i) * 1.0000001
	}
	_ = sum
}

func run(jobCount, workers int, task func()) time.Duration {
	jobs := make(chan struct{}, jobCount)
	var wg sync.WaitGroup
	start := time.Now()
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range jobs {
				task()
			}
		}()
	}
	for i := 0; i < jobCount; i++ {
		jobs <- struct{}{}
	}
	close(jobs)
	wg.Wait()
	return time.Since(start)
}

func main() {
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
	fmt.Println("\nI/O-bound (each job sleeps 20ms), 200 jobs:")
	for _, workers := range []int{1, 2, 4, 8, 16, 32, 64} {
		d := run(200, workers, ioBound)
		fmt.Printf("  %3d workers: %6v  (%.0f jobs/sec)\n", workers, d.Round(time.Millisecond), 200/d.Seconds())
	}
	fmt.Println("\nCPU-bound (each job burns a core), 200 jobs:")
	for _, workers := range []int{1, 2, 4, 8, 16} {
		d := run(200, workers, cpuBound)
		fmt.Printf("  %3d workers: %6v  (%.0f jobs/sec)\n", workers, d.Round(time.Millisecond), 200/d.Seconds())
	}
}

Output:

GOMAXPROCS: 2

I/O-bound (each job sleeps 20ms), 200 jobs:
    1 workers: 4.092s  (49 jobs/sec)
    2 workers: 2.055s  (97 jobs/sec)
    4 workers: 1.012s  (198 jobs/sec)
    8 workers:  506ms  (395 jobs/sec)
   16 workers:  268ms  (747 jobs/sec)
   32 workers: 142ms  (1410 jobs/sec)
   64 workers:  81ms  (2463 jobs/sec)

CPU-bound (each job burns a core), 200 jobs:
    1 workers:  128ms  (1560 jobs/sec)
    2 workers:   65ms  (3061 jobs/sec)
    4 workers:   64ms  (3127 jobs/sec)
    8 workers:   64ms  (3116 jobs/sec)
   16 workers:   69ms  (2881 jobs/sec)

Two different shapes. CPU-bound throughput doubles from 1 to 2 workers (both cores busy) and then flatlines; at 16 workers it is slightly slower because context switching costs more than it buys. For CPU work, set the pool to runtime.GOMAXPROCS(0) and stop. I/O-bound throughput keeps climbing well past the core count, because while 63 workers wait on their 20 ms sleep, the 2 cores stay busy servicing others. For I/O work, the limit is not your cores; it is what the downstream can take. Start around 10 to 50, then raise it until the downstream’s latency climbs or its error rate ticks up, and back off from there. Do not pick by guesswork; the numbers above took one benchmark to produce, and yours will too.

The semaphore channel: a lighter limiter for simple cases

A full pool with a jobs channel and long-lived workers is the right tool when work arrives continuously or you want to reuse workers. When you already have a slice of items in hand and just want to cap how many process at once, a buffered channel used as a counting semaphore is less code. Each goroutine acquires a slot before starting and releases it when done; the buffer size is the concurrency limit.

package main

import (
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
	"time"
)

func uploadThumbnail(id int) {
	time.Sleep(50 * time.Millisecond) // simulate an upload to object storage
	_ = id
}

func main() {
	const uploads = 200
	const limit = 8

	sem := make(chan struct{}, limit) // buffered channel = concurrency limiter
	var wg sync.WaitGroup
	var peak int64

	start := time.Now()
	for id := 1; id <= uploads; id++ {
		sem <- struct{}{} // acquire a slot; blocks when 8 are already running
		wg.Add(1)
		go func() {
			defer wg.Done()
			defer func() { <-sem }() // release the slot

			if n := int64(runtime.NumGoroutine()); n > atomic.LoadInt64(&peak) {
				atomic.StoreInt64(&peak, n)
			}
			uploadThumbnail(id)
		}()
	}
	wg.Wait()

	fmt.Printf("uploads:         %d\n", uploads)
	fmt.Printf("concurrency cap: %d\n", limit)
	fmt.Printf("peak goroutines: %d (includes main)\n", peak)
	fmt.Printf("elapsed:         %v\n", time.Since(start).Round(time.Millisecond))
}

Output (run with -race, no warnings):

uploads:         200
concurrency cap: 8
peak goroutines: 10 (includes main)
elapsed:         1.26s

The sem <- struct{}{} before the go statement is the whole trick: once 8 slots are taken, the loop blocks there, so at most 8 uploads run and the peak goroutine count stays near the limit rather than exploding to 200. Two hundred uploads at 50 ms, 8 at a time, is 25 batches, about 1.25 seconds. Prefer this when the task list is finite and you do not need results streamed back. Prefer a full pool when work is continuous, when spinning up a goroutine per item is itself too expensive, or when you want a fixed set of long-lived workers holding one connection each.

errgroup and semaphore.Weighted: the production tools

Hand-rolled pools are worth understanding because you will read them in existing code, but in new code most teams reach for two packages from golang.org/x/sync. Neither is in the standard library, so this section describes them rather than running them.

errgroup.Group is the fan-out-with-errors pattern packaged: g.Go(func() error { ... }) starts a tracked goroutine, g.Wait() returns the first non-nil error, and errgroup.WithContext cancels a shared context the moment any goroutine fails, so siblings stop early. Crucially, g.SetLimit(n) turns the group into a bounded pool in one line, exactly the cancellation-plus-bounding you built by hand above, with the boilerplate gone. If a request fans out to several backends, this is almost always the right tool.

semaphore.Weighted is the semaphore channel with two features a raw channel lacks: Acquire takes a context, so a goroutine waiting for a slot can be cancelled, and slots can have weight, so a heavy job can take 4 units of a 10-unit budget while light jobs take 1. Use it when jobs have uneven cost or when acquisition itself must respect a deadline.

The through line: a worker pool, errgroup with SetLimit, and a weighted semaphore are three points on one spectrum. All of them do the same job, which is to keep the number of concurrent operations at a number you chose on purpose.

A real batch pipeline: bounded, cancellable, error-collecting

Here is everything in one shape you would actually ship: enrich a batch of 60 user records from a slow downstream service, 10 at a time, collecting every failure, under an overall timeout, with a clean shutdown and no leaked goroutines. It also times itself against the sequential version so the payoff is concrete.

package main

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"sync"
	"time"
)

type Job struct{ UserID int }

// Result carries either the enriched value or the error for that job.
type Result struct {
	UserID  int
	Profile string
	Err     error
}

// enrich simulates a 40ms downstream call that fails for a few IDs.
func enrich(ctx context.Context, userID int) (string, error) {
	select {
	case <-time.After(40 * time.Millisecond):
		if userID%13 == 0 {
			return "", fmt.Errorf("user %d: downstream 404", userID)
		}
		return fmt.Sprintf("profile(%d)", userID), nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

// ProcessBatch runs jobs through workerCount workers, bounded and cancellable.
func ProcessBatch(ctx context.Context, jobs []Job, workerCount int) []Result {
	jobCh := make(chan Job)
	resultCh := make(chan Result)

	go func() { // feeder: stop early if the context is cancelled
		defer close(jobCh)
		for _, job := range jobs {
			select {
			case jobCh <- job:
			case <-ctx.Done():
				return
			}
		}
	}()

	var wg sync.WaitGroup
	for w := 0; w < workerCount; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for job := range jobCh {
				profile, err := enrich(ctx, job.UserID)
				resultCh <- Result{UserID: job.UserID, Profile: profile, Err: err}
			}
		}()
	}

	go func() { // closer: close results only after every worker returns
		wg.Wait()
		close(resultCh)
	}()

	var results []Result
	for res := range resultCh {
		results = append(results, res)
	}
	return results
}

func processSequential(jobs []Job) time.Duration {
	start := time.Now()
	for _, job := range jobs {
		_, _ = enrich(context.Background(), job.UserID)
	}
	return time.Since(start)
}

func main() {
	jobs := make([]Job, 60)
	for i := range jobs {
		jobs[i] = Job{UserID: i + 1}
	}

	seq := processSequential(jobs)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	start := time.Now()
	results := ProcessBatch(ctx, jobs, 10)
	pooled := time.Since(start)

	var failures []error
	ok := 0
	for _, res := range results {
		if res.Err != nil {
			failures = append(failures, res.Err)
			continue
		}
		ok++
	}
	sort.Slice(failures, func(i, j int) bool { return failures[i].Error() < failures[j].Error() })

	fmt.Printf("jobs:        %d\n", len(jobs))
	fmt.Printf("succeeded:   %d\n", ok)
	fmt.Printf("failed:      %d\n", len(failures))
	for _, err := range failures {
		fmt.Println("  -", err)
	}
	fmt.Printf("sequential:  %v\n", seq.Round(time.Millisecond))
	fmt.Printf("pooled (10): %v\n", pooled.Round(time.Millisecond))
	fmt.Printf("speedup:     %.1fx\n", float64(seq)/float64(pooled))
	fmt.Printf("all errors joined non-nil: %v\n", errors.Join(failures...) != nil)
}

Output (run with -race, no warnings):

jobs:        60
succeeded:   56
failed:      4
  - user 13: downstream 404
  - user 26: downstream 404
  - user 39: downstream 404
  - user 52: downstream 404
sequential:  2.433s
pooled (10): 243ms
speedup:     10.0x
all errors joined non-nil: true

A clean 10x, which is what you expect from 10 workers on I/O-bound work: 60 jobs at 40 ms is 2.4 seconds sequentially, six batches of 40 ms pooled. ProcessBatch is a self-contained function you could drop into a service: the caller owns the context and the timeout, the feeder and closer goroutines both exit cleanly, results carry their own errors, and nothing leaks because every goroutine’s exit condition (channel closed, or context done) is guaranteed. Swap enrich for a real database or HTTP call, pass the request’s context in, and this is production code.

Common worker pool mistakes

Closing the results channel from a worker. Each worker closes results after its range, so the second worker to finish sends on a closed channel, or closes an already-closed channel. Both panic:

panic: send on closed channel

The fix is the closer goroutine from every example above: one place calls close(results), and only after wg.Wait() confirms all senders are done.

Deadlock from the wrong close and collect order. If main sends every job before receiving any result, and results is unbuffered, the workers block trying to send results while main blocks trying to send jobs. Nobody receives, and the runtime notices:

fatal error: all goroutines are asleep - deadlock!

Fix it by collecting results concurrently with feeding: feed jobs from one goroutine and range results in another (or in main), as the closing section does. Sending all input before reading any output only works when both channels are buffered to the full job count.

Unbounded spawning disguised as a pool. Starting a goroutine per job inside a loop and calling it a pool bounds nothing. If there is no fixed worker count and no semaphore, and the concurrency equals the number of jobs, it is the 50,000-goroutine explosion from the top of this article wearing a costume. A real pool has a number you chose: worker count, buffer size, or SetLimit.

Ignoring context. A pool with no cancellation path runs every queued job even after the client has hung up or one job has already doomed the request. Every worker loop should check ctx.Err() before taking new work, and every blocking call inside a job should take a context and select on ctx.Done().

Picking the worker count by guesswork. A hardcoded numWorkers := 100 with no reasoning is how you either starve throughput on CPU work or flatten a downstream on I/O work. Decide from the job type: GOMAXPROCS for CPU-bound, and for I/O-bound a number you raised under load until the downstream pushed back. The benchmark in the sizing section is a template you can point at your real workload.

What next

You can now build a pool that bounds concurrency, propagates errors, cancels on demand, shuts down cleanly, and is sized on evidence instead of a guess. Go deeper on the pieces it is built from:

Run each example yourself with -race on, then change the worker count and watch the timing move. The numbers teach the pattern faster than the prose can.