How to Implement a Job Queue in Go

Implement a Go job queue using a buffered channel for tasks and goroutines for concurrent processing.

The coffee shop ticket rail

You have a web service. A user uploads a video. You can't make them wait 30 seconds for encoding. You accept the upload, return "202 Accepted," and process the video in the background. Another user comes in. Then another. Suddenly you have 50 videos waiting.

If you spawn a goroutine for every video, you'll run out of memory or crash the database with too many connections. You need a queue. You need workers. You need to control how many things happen at once.

A job queue in Go is usually a buffered channel paired with a fixed set of goroutines. The channel holds the jobs. The goroutines are the workers. The buffer size limits how many jobs can pile up. The worker count limits concurrency.

Think of a coffee shop. The channel is the ticket rail. The buffer is how many tickets fit on the rail. The workers are the baristas. If the rail is full, the customer has to wait. If there are only two baristas, only two drinks get made at once, no matter how many tickets are on the rail.

Minimal worker pool

Here's the skeleton: a buffered channel holds jobs, three goroutines pull from it, and a WaitGroup tracks completion.

package main

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

// worker drains the channel until closed.
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		time.Sleep(time.Second) // Simulate processing delay.
		fmt.Printf("Worker %d processed job %d\n", id, j)
	}
}

func main() {
	jobs := make(chan int, 10) // Buffer limits queue depth.
	var wg sync.WaitGroup

	for w := 1; w <= 3; w++ { // Launch 3 workers.
		wg.Add(1)
		go worker(w, jobs, &wg)
	}

	for j := 1; j <= 5; j++ { // Enqueue jobs.
		jobs <- j
	}
	close(jobs) // Signal workers to exit after drain.
	wg.Wait()   // Wait for completion.
}

The channel is the queue. make(chan int, 10) creates a buffer. Sends to a buffered channel are non-blocking until the buffer is full. The workers use range jobs. This loops until the channel is closed and empty.

close(jobs) is crucial. Without it, workers block forever on the next read. The WaitGroup ensures main doesn't exit while workers are running. If main exits, the process dies, and workers vanish.

Pass *sync.WaitGroup to workers. The WaitGroup holds internal state that must be shared. Passing a copy breaks the synchronization. The convention is to pass the pointer.

Close the channel when you're done sending. Range loops wait for the close.

Sizing the queue and workers

The buffer acts as a shock absorber. If producers are faster than consumers, the buffer absorbs the burst. If the buffer is zero, every send blocks until a worker is ready. This couples the producer and worker tightly. A small buffer decouples them slightly but still backpressures quickly. A large buffer decouples more but uses memory.

If the buffer is too large, you might hide performance problems. The queue fills up, memory grows, and the system crashes before the producer realizes workers are slow. Pick a buffer size based on your memory budget and acceptable latency.

Worker count depends on the workload. If the job is CPU-bound, match the number of logical CPUs. runtime.NumCPU() gives you that. If the job is I/O-bound, you can have more workers because they spend time waiting for disks or networks. A common heuristic is NumCPU * 2 or NumCPU * 4 for I/O.

Don't spawn infinite workers. That's not a queue. That's a resource leak waiting to happen. Bounded concurrency protects downstream services and keeps your process stable.

Goroutines are cheap. Channels are not magic.

Realistic payloads and errors

Real queues handle payloads, errors, and shutdown signals. Here's a job struct and a processor that checks context.

package main

import (
	"context"
	"fmt"
)

// Job carries the payload for a single task.
type Job struct {
	ID   int
	Data string
}

// processJob executes the work. It respects context cancellation.
func processJob(ctx context.Context, j Job) error {
	select {
	case <-ctx.Done():
		return ctx.Err() // Propagate cancellation.
	default:
		if j.Data == "fail" {
			return fmt.Errorf("job %d failed", j.ID)
		}
		fmt.Printf("Processed job %d\n", j.ID)
		return nil
	}
}

The worker loops over jobs, processes them, and pushes errors to a separate channel. The select with default avoids blocking if the error channel is full.

// worker pulls jobs and reports errors.
func worker(ctx context.Context, id int, jobs <-chan Job, errs chan<- error, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		err := processJob(ctx, j)
		if err != nil {
			// Non-blocking send prevents deadlock if error consumer is slow.
			select {
			case errs <- err:
			default:
			}
		}
	}
}

context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This pattern lets you cancel the entire queue from a single point. If the server is shutting down, you cancel the context. Workers check ctx.Done() and stop.

The receiver name is usually one or two letters matching the type. (j Job) is standard. Avoid (this Job) or (self Job). Go doesn't use those keywords.

Context is plumbing. Run it through every long-lived call site.

Backpressure and shutdown

Backpressure is the mechanism where a slow consumer forces the producer to wait. In a buffered channel, this happens when the buffer is full. The send operation blocks. The producer goroutine pauses. This propagates up the call stack.

If a web handler sends a job and blocks, the HTTP request hangs. This tells the client "I'm busy." It's better to hang the request than to accept it and lose the job or crash the server. Design your API to handle this. Return errors if the queue is full, or use a timeout.

Shutdown requires coordination. You can't just kill the process. You need to stop accepting jobs, drain the queue, and let workers finish. Close the input channel to signal no more jobs. Workers drain the remaining items. The WaitGroup tracks when workers are done.

Use a context with a timeout to force exit if workers hang. context.WithTimeout gives you a deadline. If workers don't finish by then, you abort. This prevents the server from hanging indefinitely during a restart.

The worst goroutine bug is the one that never logs.

Pitfalls and compiler errors

If you forget to close the channel, workers block on range. The program hangs. The runtime eventually detects this and panics with fatal error: all goroutines are asleep - deadlock!.

If you send to a full buffered channel and no worker is reading, the sender blocks. If the sender is the only goroutine left, deadlock. The compiler won't catch this. It's a runtime logic error.

If you call wg.Wait() before wg.Add(), the wait returns immediately. The program exits while workers are still running. This is a race condition. Always add to the WaitGroup before launching the goroutine.

Forgetting to capture the loop variable in a closure used to be a common trap. In older Go versions, go func() { fmt.Println(i) }() inside a loop would print the final value of i for all goroutines. Go 1.22 changed loop variable semantics to fix this, but the compiler warns with loop variable i captured by func literal if you try the old pattern in newer versions.

Don't pass a *string. Strings are already cheap to pass by value. They contain a pointer and length. Passing a pointer adds indirection without saving memory.

Accept interfaces, return structs. If your queue needs to handle different job types, define an interface for the job. Return concrete structs from factories. This keeps the queue generic while allowing specific implementations.

When to use a job queue

Use a buffered channel and worker pool when you need to throttle concurrency and queue tasks in memory.

Use a single goroutine feeding a channel when one producer streams data to one consumer.

Use a task queue library when jobs must survive process restarts or run across multiple servers.

Use golang.org/x/sync/errgroup when you want concurrent execution with automatic cancellation on the first error.

Use sequential code when the task count is low and the overhead of goroutines outweighs the speed gain.

Where to go next