Concurrency in Go Web Services
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()
}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 criticalFor 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
- Add a handler that fetches book + fake review service in parallel with a 1s timeout.
- Break it intentionally (remove Wait) and observe incomplete responses or races.
- Run
go test -raceon your memory repository under concurrent Create/List.