Concurrency in Go Web Services

Updated

September 8, 2026

Concurrency in Go Web Services

Overview

Each HTTP request already runs on its own goroutine under net/http. Extra concurrency pays off when a single handler must fetch several independent resources, call external APIs, or process batches—without blocking the whole response on serial I/O. This chapter applies Go concurrency tools to Bookstore-style backends and avoids the usual deadlocks and leaks.

For a full concurrency curriculum (diagrams, Cond, atomics), use part 21 (Concurrency From the Ground Up). Here we stay web-focused.

What the server already gives you

  Client A ──► goroutine ──► handler A
  Client B ──► goroutine ──► handler B
  Client C ──► goroutine ──► handler C

You do not start a goroutine per request yourself. You start goroutines inside a handler when work can run in parallel, then wait before writing the response.

Parallel independent fetches

Example: book detail page needs book metadata + review summary + stock from three services.

func (s *Server) bookDashboard(ctx context.Context, id string) (Dashboard, error) {
    var (
        book    domain.Book
        reviews []Review
        stock   int
        eg      errgroup.Group
    )

    eg.Go(func() error {
        var err error
        book, err = s.books.Get(ctx, id)
        return err
    })
    eg.Go(func() error {
        var err error
        reviews, err = s.reviews.ListForBook(ctx, id)
        return err
    })
    eg.Go(func() error {
        var err error
        stock, err = s.inventory.Stock(ctx, id)
        return err
    })

    if err := eg.Wait(); err != nil {
        return Dashboard{}, err
    }
    return Dashboard{Book: book, Reviews: reviews, Stock: stock}, nil
}

Use golang.org/x/sync/errgroup (or errgroup.WithContext) so the first failure cancels siblings when you wire context.

With cancel on first error

eg, ctx := errgroup.WithContext(ctx)
// eg.Go(...) as above
err := eg.Wait()

Timeouts and context

Always bound outbound work:

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
// client.Do(req)

Pattern:

  request context (client gone)
         │
         ▼
  timeout child (2s)
         │
    ┌────┴────┐
    ▼         ▼
  reviews   inventory

If the browser disconnects, r.Context() cancels and well-behaved queries stop.

Worker pool for bulk work

Importing a CSV of books: do not spawn one goroutine per row unbounded.

func importBooks(ctx context.Context, rows []BookRow, workers int, save func(context.Context, BookRow) error) error {
    jobs := make(chan BookRow)
    eg, ctx := errgroup.WithContext(ctx)

    for i := 0; i < workers; i++ {
        eg.Go(func() error {
            for row := range jobs {
                if err := save(ctx, row); err != nil {
                    return err
                }
            }
            return nil
        })
    }

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

    return eg.Wait()
}

Shared state in handlers

In-memory repositories and caches need synchronization:

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    c.n++
    c.mu.Unlock()
}

Prefer:

  1. No shared mutable state (DB owns truth)
  2. Channels / ownership for pipelines
  3. Mutex for small in-process caches
  4. atomics only for simple counters

Run tests with go test -race.

Deadlock patterns to avoid

Bug Symptom Fix
Unbuffered send with no receiver Hang Ensure consumer, or buffer deliberately
WaitGroup Add inside goroutine Under-count Add before go, or use WaitGroup.Go
Lock held across slow I/O Latency spike Copy data under lock, I/O outside
Waiting on channel that never closes Hang defer close, or select on ctx.Done()

HTTP client reuse

var paymentClient = &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    },
}

Do not create a new http.Client per request without reason—connection pooling lives on the client/transport.

Fire-and-forget (careful)

Logging metrics asynchronously is fine if you bound queues. Starting a goroutine that can block forever without tracking is not:

// risky: no backpressure, no shutdown
go sendEmail(order)

// better: queue + worker, or wait with timeout in request when email is critical

For web handlers, prefer completing critical work within the request or enqueueing to a durable job system.

Rules of thumb

Do Don’t
Parallelize independent I/O with errgroup Spawn unlimited goroutines per upload
Propagate and timeout contexts Ignore r.Context()
Protect shared maps with mutex Assume request isolation covers package-level maps
Reuse HTTP clients http.Get in a tight loop without reuse thinking

Try next

  1. Add a handler that fetches book + fake review service in parallel with a 1s timeout.
  2. Break it intentionally (remove Wait) and observe incomplete responses or races.
  3. Run go test -race on your memory repository under concurrent Create/List.