Background Workers
Move slow work out of the request with a goroutine and a channel, and keep it alive across shutdown.
jobs := make(chan Job, 100); go func() { for j := range jobs { handle(j) } }()A web handler should answer quickly. Some work is slow: resizing an image, sending an email, rebuilding a report. A background worker is code that does that slow work outside the request, so the response can go out straight away.
How it works
When the program starts, start a goroutine. Give it a channel to read jobs from. The handler writes a job onto the channel and returns. The worker reads the job and does the work. Several goroutines can read the same channel, and each job goes to exactly one of them. A group like that is called a worker pool.
The channel is usually buffered, which means it can hold a few jobs that nobody has picked up yet. A buffer of 100 lets a burst of requests through without making them wait. It also gives you somewhere to say no: if the buffer is full, the handler can reject the request instead of blocking.
What trips people up
Two things. A request's context is cancelled the moment the handler returns, so a goroutine that uses r.Context() is cancelled immediately. Pass context.WithoutCancel(r.Context()) (Go 1.21) or a context the worker owns. Second, http.Server.Shutdown waits for handlers, not for goroutines you started yourself. Keep your own sync.WaitGroup, and wait on it after Shutdown returns, or every job in flight disappears on each deploy.
Example
package main
import (
"fmt"
"slices"
"sync"
)
func main() {
jobs := make(chan int)
results := make(chan string)
var wg sync.WaitGroup
for range 3 { // three workers, all reading the same channel
// wg.Go (Go 1.25) is shorthand for:
// wg.Add(1); go func() { defer wg.Done(); ... }()
wg.Go(func() {
for n := range jobs {
results <- fmt.Sprintf("squared %d = %d", n, n*n)
}
})
}
go func() {
for n := 1; n <= 4; n++ {
jobs <- n
}
close(jobs) // no more work, so each worker's loop ends
wg.Wait()
close(results)
}()
var lines []string
for line := range results {
lines = append(lines, line)
}
slices.Sort(lines) // workers finish in any order; sort for a stable printout
for _, line := range lines {
fmt.Println(line)
}
}
// Output:
// squared 1 = 1
// squared 2 = 4
// squared 3 = 9
// squared 4 = 16
When to use it
- Return 202 from a signup handler and send the welcome email afterwards.
- Resize an uploaded image after the upload response has already gone out.
- Spread a nightly export across four workers instead of one slow loop.
- Keep jobs that are in flight alive through a rolling deploy.
More examples
Enqueue from a handler, and reject when the queue is full
The send is wrapped in a select with a default case, so it never blocks: if the buffer is full the request fails fast instead of holding a connection open. Reach for this whenever a handler hands work off, because an unbounded queue just moves the outage somewhere harder to see.
type emailJob struct {
ctx context.Context
addr string
}
var jobs = make(chan emailJob, 100)
func signup(w http.ResponseWriter, r *http.Request) {
// r.Context() is cancelled as soon as this handler returns.
// WithoutCancel (Go 1.21) keeps the context's values and drops the cancellation.
job := emailJob{ctx: context.WithoutCancel(r.Context()), addr: r.FormValue("email")}
select {
case jobs <- job:
w.WriteHeader(http.StatusAccepted) // 202: accepted, not finished
default:
// The buffer is full. Saying so beats queueing without limit.
http.Error(w, "busy, try again", http.StatusServiceUnavailable)
}
}Drain the pool as part of shutdown
The order is the whole point: shut the server first so nothing new is enqueued, then close the channel, then wait. Use this in any service with background work, otherwise a rolling deploy silently drops whatever was buffered.
func serve(mux http.Handler, stop <-chan struct{}) {
jobs := make(chan emailJob, 100)
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
for j := range jobs {
send(j)
}
})
}
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() { _ = srv.ListenAndServe() }()
<-stop // closed when the process is asked to exit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx) // stops accepting and waits for running handlers
close(jobs) // no handler can send now, so closing is safe
wg.Wait() // finish the jobs already in the buffer
}One goroutine per job, with a limit
Sometimes a long-lived pool is more machinery than you need. This keeps the simplicity of starting a goroutine per job while putting a ceiling on how many run together. Use it for occasional work; use a pool when jobs arrive constantly.
// sem is a counting semaphore: a channel used only for its capacity.
// At most 8 background jobs run at once.
var sem = make(chan struct{}, 8)
func background(f func()) {
sem <- struct{}{} // blocks while 8 jobs are already running
go func() {
defer func() { <-sem }() // free the slot even if f panics
f()
}()
}
Discussion