In modern backend systems, it's common to offload time-consuming tasks—like sending emails, resizing images, or processing reports—away from the main request flow. This is where worker queues come in. A worker queue allows you to schedule and process tasks in the background using a pool of workers.
Golang is especially well-suited for building high-performance concurrent systems thanks to its lightweight goroutines and powerful channel primitives. In this tutorial, you'll learn how to build a simple yet scalable worker queue in Go—ideal for any background job processing system.
By the end of this guide, you’ll be able to:
-
Create a configurable worker pool
-
Dispatch and queue jobs concurrently
-
Gracefully handle shutdowns and failures
-
Measure performance and scalability
Let’s get started!
1. Prerequisites
Before we begin, make sure you have the following:
-
Go 1.21 or later installed (install here)
-
A terminal or command-line interface
-
A code editor (e.g., VS Code or GoLand)
-
Basic understanding of:
-
Goroutines
-
Channels
-
Structs and interfaces in Go
-
You can verify your Go installation by running:
go version
You should see something like:
go version go1.24.3 darwin/arm64
2. Project Setup
Let's initialize a simple Go project for our worker queue.
Step 1: Create a project directory
mkdir go-worker-queue
cd go-worker-queue
Step 2: Initialize a Go module
go mod init github.com/yourusername/go-worker-queue
Step 3: Project Structure
Here's the structure we’ll use:
go-worker-queue/
│
├── main.go # Entry point
├── job.go # Job definition
├── worker.go # Worker logic
└── dispatcher.go # Dispatcher to manage workers and job queue
We'll start with minimal dependencies—pure Go code using the context
, sync
, and os
packages.
3. Understanding the Worker Queue Architecture
Before we dive into writing code, it’s important to understand the architecture of a worker queue system in Go. The main goal is to produce jobs, queue them, and have multiple workers process them concurrently, efficiently and safely.
🔄 Core Components
Our implementation will consist of the following components:
1. Job
A simple struct representing a task to be executed. It could be anything: send an email, resize an image, write to a database, etc.
2. Worker
A long-running goroutine that listens for jobs and processes them. Each worker pulls jobs from a shared job queue.
3. Dispatcher
A coordinator who:
-
Spins up multiple workers
-
Manages a shared job queue
-
Accepts incoming jobs from the producer and pushes them into the queue
4. Job Queue
A channel that buffers and distributes jobs to workers.
📊 How the Flow Works
Here’s a high-level overview:
🧩 Communication Using Channels
Go’s channels make this architecture clean and performant:
-
jobQueue chan Job
: holds jobs to be processed. -
Each worker listens to the
jobQueue
and executes jobs as they arrive. -
The dispatcher creates and manages workers and keeps the system running.
🧯 Graceful Shutdown (Preview)
We'll also add:
-
Context-based cancellation
-
OS signal trapping
-
WaitGroup to ensure no job is left behind during shutdown
4. Implementing the Job and Worker
Let’s start coding! We’ll first define a basic job and then implement a worker that continuously pulls and processes jobs from the queue.
📄 job.go
: Define the Job
A job can be any task. For simplicity, let’s simulate a job that prints a message and sleeps for a short duration.
// job.go
package main
import (
"fmt"
"time"
)
// Job represents a task to be processed
type Job struct {
ID int
Message string
}
// Process executes the actual job logic
func (j Job) Process() {
fmt.Printf("Processing Job #%d: %s\n", j.ID, j.Message)
time.Sleep(1 * time.Second) // simulate workload
fmt.Printf("Finished Job #%d\n", j.ID)
}
📄 worker.go
: Implement the Worker
Each worker will run in its goroutine and listen on a Job
channel.
// worker.go
package main
import (
"context"
"fmt"
)
// Worker represents a single worker that processes jobs
type Worker struct {
ID int
JobQueue <-chan Job // Read-only channel for jobs
Context context.Context // Used for cancellation
}
// Start begins the worker loop in a goroutine
func (w *Worker) Start() {
go func() {
fmt.Printf("Worker #%d started\n", w.ID)
for {
select {
case <-w.Context.Done():
fmt.Printf("Worker #%d stopping...\n", w.ID)
return
case job, ok := <-w.JobQueue:
if !ok {
fmt.Printf("Worker #%d: job queue closed\n", w.ID)
return
}
job.Process()
}
}
}()
}
🧠 Key Notes:
-
Each worker runs concurrently in its goroutine.
-
It continuously listens for new jobs from
JobQueue
. -
It checks for cancellation signals via
context.Context
.
5. Building the Dispatcher
The Dispatcher is responsible for:
-
Creating and starting a pool of workers.
-
Managing a centralized job queue.
-
Receiving jobs from producers and feeding them to the job queue.
📄 dispatcher.go
: Dispatcher Implementation
// dispatcher.go
package main
import (
"context"
"sync"
)
// Dispatcher manages a pool of workers and a job queue
type Dispatcher struct {
WorkerCount int
JobQueue chan Job
workers []*Worker
ctx context.Context
cancel context.CancelFunc
wg *sync.WaitGroup
}
// NewDispatcher creates and initializes a Dispatcher
func NewDispatcher(workerCount int, queueSize int) *Dispatcher {
ctx, cancel := context.WithCancel(context.Background())
return &Dispatcher{
WorkerCount: workerCount,
JobQueue: make(chan Job, queueSize),
ctx: ctx,
cancel: cancel,
wg: &sync.WaitGroup{},
}
}
// Start initializes the worker pool and begins processing jobs
func (d *Dispatcher) Start() {
for i := 1; i <= d.WorkerCount; i++ {
worker := &Worker{
ID: i,
JobQueue: d.JobQueue,
Context: d.ctx,
}
d.workers = append(d.workers, worker)
worker.Start()
}
}
// Submit enqueues a job into the job queue
func (d *Dispatcher) Submit(job Job) {
d.wg.Add(1)
go func() {
d.JobQueue <- job
d.wg.Done()
}()
}
// Stop gracefully shuts down the dispatcher and its workers
func (d *Dispatcher) Stop() {
// Cancel context to stop all workers
d.cancel()
// Wait for any pending submissions to complete
d.wg.Wait()
// Close the job queue
close(d.JobQueue)
}
🔍 How It Works
-
NewDispatcher
: Initializes the dispatcher with a context, a buffered job queue, and a wait group. -
Start
: Launches the defined number of workers. -
Submit
: Adds jobs to the job queue in a goroutine. -
Stop
: Signals workers to exit and waits for job submission goroutines to finish before closing the job queue.
6. Creating a Job Producer
The producer is responsible for generating or receiving tasks (e.g., from an API or a cron job) and feeding them into the dispatcher’s queue.
For this tutorial, we’ll simulate job production using a simple loop that submits multiple jobs with a delay.
📄 main.go
: Putting It All Together
// main.go
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
workerCount := 4
queueSize := 10
fmt.Println("Starting dispatcher...")
dispatcher := NewDispatcher(workerCount, queueSize)
dispatcher.Start()
// Simulate job production
go func() {
for i := 1; i <= 20; i++ {
job := Job{
ID: i,
Message: fmt.Sprintf("Job number %d", i),
}
fmt.Printf("Submitting Job #%d\n", job.ID)
dispatcher.Submit(job)
time.Sleep(500 * time.Millisecond) // simulate incoming rate
}
}()
// Handle OS signals for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan // wait for interrupt
fmt.Println("\nShutting down gracefully...")
dispatcher.Stop()
fmt.Println("All workers stopped. Exiting.")
}
🧪 What This Does
-
Starts a dispatcher with 4 workers and a buffered queue.
-
Launches a goroutine that produces 20 jobs at 0.5-second intervals.
-
Submits each job to the dispatcher’s queue.
-
Listens for
SIGINT
(Ctrl+C) orSIGTERM
to trigger a graceful shutdown.
📦 Run the Program
go run main.go dispatcher.go worker.go job.go
You should see output like:
Starting dispatcher...
Worker #1 started
Worker #2 started
...
Submitting Job #1
Processing Job #1: Job number 1
Finished Job #1
...
^C
Shutting down gracefully...
Worker #1 stopping...
...
All workers stopped. Exiting.
7. Graceful Shutdown and Error Handling
In real-world systems, your worker queue must handle:
-
OS interrupts (like
Ctrl+C
) -
Ongoing jobs finishing before shutdown
-
Context cancellation to avoid goroutine leaks
-
Logging and retry mechanisms (optional in this version)
Let’s extend what we’ve already written to ensure clean exits.
Improvements to main.go
We already have signal handling. Now let’s make sure:
-
Submitted jobs are processed before exit
-
We cancel all workers via context
-
The dispatcher waits for all
Submit()
calls to complete before closing the queue
Here’s the updated shutdown logic:
// main.go (partial)
func main() {
workerCount := 4
queueSize := 10
fmt.Println("Starting dispatcher...")
dispatcher := NewDispatcher(workerCount, queueSize)
dispatcher.Start()
// Simulate job production
go func() {
for i := 1; i <= 20; i++ {
job := Job{
ID: i,
Message: fmt.Sprintf("Job number %d", i),
}
fmt.Printf("Submitting Job #%d\n", job.ID)
dispatcher.Submit(job)
time.Sleep(500 * time.Millisecond)
}
}()
// OS signal handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
fmt.Println("\nShutting down gracefully...")
dispatcher.Stop()
fmt.Println("All workers stopped. Exiting.")
}
Improvements to dispatcher.go
We already use context.CancelFunc
and sync.WaitGroup
:
-
ctx.Cancel()
notifies workers to stop. -
wg.Wait()
ensures all job submissions are flushed before closing the queue.
This ensures we don’t lose any jobs already in the queue.
If you want to wait until the job queue is empty, you can block using an additional WaitGroup for running jobs, but for this tutorial, we rely on the buffered channel + context.
Optional: Add Error Handling in Jobs
In job.go
, you can simulate and log errors:
// job.go (modified)
func (j Job) Process() {
fmt.Printf("Processing Job #%d: %s\n", j.ID, j.Message)
time.Sleep(1 * time.Second)
// Simulate error
if j.ID%7 == 0 {
fmt.Printf("⚠️ Job #%d failed: simulated error\n", j.ID)
return
}
fmt.Printf("Finished Job #%d\n", j.ID)
}
You can also add a retry mechanism or push failed jobs to a separate queue, but we'll keep it simple for now.
8. Performance Considerations and Tuning
A good worker queue should scale with demand and efficiently utilize system resources. Go makes this easier thanks to its lightweight concurrency model, but a poorly tuned queue can still become a bottleneck.
Here’s how to measure and improve your system.
1. Measure Throughput and Latency
Add basic timing metrics to main.go
:
totalJobs := 20
start := time.Now()
// submit jobs...
for i := 1; i <= totalJobs; i++ {
// ...
}
dispatcher.Stop()
elapsed := time.Since(start)
fmt.Printf("Processed %d jobs in %s\n", totalJobs, elapsed)
Example output:
Processed 20 jobs in 5.038s
You can calculate:
-
Throughput = total jobs / elapsed time
-
Average latency per job = elapsed time / total jobs
2. Benchmark with Varying Worker Counts
Try running the system with different values:
workerCount := 1 // Try 1, 2, 4, 8, etc.
queueSize := 10
totalJobs := 100
Compare execution time:
Workers | Time to Process 100 Jobs |
---|---|
1 | ~100s |
2 | ~50s |
4 | ~25s |
8 | ~13s |
3. Tune Buffer Size (queueSize
)
A larger buffer helps absorb traffic spikes. Start with:
queueSize := totalJobs
If the buffer is too small, job submissions may block. If too large, you risk unbounded memory usage (especially with large payloads).
4. Avoid Common Pitfalls
-
Deadlocks: Always close your job channel once job production is done.
-
Blocking: Don’t let one slow worker block all others—each worker runs independently.
-
Goroutine leaks: Use
context.Context
andsync.WaitGroup
to clean up properly.
5. Scale Horizontally (Optional)
If a single machine can’t handle enough workers:
-
Move to distributed queues like RabbitMQ, Kafka, or Redis
-
Use Go workers as independent services pulling from the central queue
Tips for Production
-
Add structured logging with levels
-
Use Prometheus or StatsD for metrics
-
Introduce exponential backoff + retry
-
Implement job priorities and deadlines
9. Testing the Worker Queue
Testing your worker queue ensures it behaves correctly under different scenarios: concurrency, job failures, queue limits, and graceful shutdowns.
We’ll cover:
-
Unit testing individual components
-
Integration testing the whole system
-
Observing output and behavior
1. Unit Testing Job.Process()
Let’s add a basic test for Job
processing.
package main
import (
"testing"
"time"
)
func TestJobProcess(t *testing.T) {
job := Job{
ID: 1,
Message: "Test job",
}
start := time.Now()
job.Process()
elapsed := time.Since(start)
if elapsed < 1*time.Second {
t.Errorf("Job processed too quickly: %v", elapsed)
}
}
✅ This checks that
Process()
simulates work correctly (with a 1-second sleep).
2. Integration Test: Dispatcher and Workers
Here’s how to test the full system (without waiting for real-time):
// dispatcher_test.go
package main
import (
"testing"
"time"
)
func TestDispatcher(t *testing.T) {
dispatcher := NewDispatcher(2, 5)
dispatcher.Start()
totalJobs := 5
for i := 0; i < totalJobs; i++ {
job := Job{
ID: i,
Message: "Test",
}
dispatcher.Submit(job)
}
time.Sleep(2 * time.Second) // allow jobs to complete
dispatcher.Stop()
}
You can assert logs or add a mock to track job completion instead of sleeping.
3. Test Graceful Shutdown
Simulate sending SIGINT using context and ensure no jobs are lost:
func TestGracefulShutdown(t *testing.T) {
dispatcher := NewDispatcher(2, 10)
dispatcher.Start()
go func() {
for i := 0; i < 10; i++ {
dispatcher.Submit(Job{ID: i, Message: "Test"})
time.Sleep(100 * time.Millisecond)
}
}()
time.Sleep(1 * time.Second) // let a few jobs run
dispatcher.Stop()
// No panics, deadlocks, or unprocessed jobs expected
}
4. Mocking Job Logic (Optional)
To avoid real delays in tests, you can override Job.Process()
with a mock that tracks calls or execution time without sleep
.
5. Observing Logs and Output
During development, observe:
-
Worker startup/shutdown logs
-
Job execution order and overlap (due to concurrency)
-
Proper shutdown handling when you
Ctrl+C
the app
Your queue is now tested, concurrent, and resilient.
Conclusion
In this tutorial, you built a high-performance worker queue in Golang from scratch using idiomatic concurrency patterns like goroutines, channels, and context. You learned how to design and implement a scalable system that can handle background tasks efficiently and shut down gracefully.
Golang’s built-in concurrency model allows you to create a robust job processing system without third-party dependencies—ideal for microservices, batch processors, task schedulers, and other backend services.
Whether you're offloading email sending, media processing, or background database work, this queue architecture is a solid foundation that you can scale and extend.
Key Takeaways
-
✅ Worker queues decouple task processing from request handling and improve responsiveness.
-
⚙️ Goroutines and channels make it easy to implement concurrent systems in Go.
-
📦 Buffered job queues absorb bursty loads and help avoid blocking producers.
-
🧹 Graceful shutdowns with
context
and signal handling prevent data loss and leaks. -
📈 Performance tuning via worker pool size and queue depth is critical for scaling.
-
🔬 Unit and integration testing ensure reliability and concurrency safety.
You can get the full source code on our GitHub.
That is just the basics. If you need more deep learning about the Golang (Go) language and frameworks, you can take the following cheap course:
- Real-World GoLang Project: Car Management System
- Building GUI Applications with Fyne and Go (Golang)
- AWS Cognito Using Golang
- Google Go Programming for Beginners (golang)
- Building a module in Go (Golang)
- Go/Golang Professional Interview Questions
Thanks!