Producer Β· Worker Β· Redis Queue Β· Real-time Dashboard Β· Cloud Deployed
| π Dashboard | π‘ Producer API | π Worker Metrics |
|---|---|---|
| distributed-task-processing-system.vercel.app | workqueue-producer-kz2k.onrender.com | worker.onrender.com/metrics |
Imagine you're building a food delivery app. A user places an order and hits "Place Order".
Behind the scenes, your server needs to:
- Save the order to the database
- Send a confirmation email to the user
- Send an SMS notification
- Notify the restaurant
- Generate and send a PDF receipt
Without a task queue, all of this happens inside the same API call:
User clicks "Place Order"
β
Server saves order ~50ms
Server sends email ~800ms β waiting for Gmail
Server sends SMS ~600ms β waiting for Twilio
Server notifies restaurant ~400ms β waiting for their API
Server generates PDF ~300ms β CPU-heavy work
β
User sees "Order Confirmed" after ~2.1 seconds π΄
Your user stares at a spinner for 2 full seconds. If any one of those external services is slow or down, your entire API hangs β or worse, times out.
With a task queue, your API does one thing: save the order and respond. Everything else is handed off to background workers.
User clicks "Place Order"
β
Server saves order ~50ms
Server pushes 4 tasks ~5ms β just adding items to a list
β
User sees "Order Confirmed" in ~55ms π
Meanwhile, in the background:
Worker 1 β sends confirmation email
Worker 2 β sends SMS
Worker 3 β notifies restaurant + generates PDF
The user gets a response 40Γ faster. The slow work still happens β just not in the user's way. And if the email provider is down, only that task fails and retries. Your API keeps running perfectly.
This is the exact pattern used by companies like Uber (job dispatch), Airbnb (email/notifications), and GitHub (CI pipeline triggers).
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your Application β
ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β POST /enqueue (JSON task)
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Producer Service β
β Validates β Serialises β RPUSH to Redis β
β Returns HTTP 200 instantly β your app resumes β
ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β RPUSH
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Redis Queue β
β task_queue (FIFO list) β
ββββββββ¬ββββββββββββββββββββ¬βββββββββββββββββββββββ¬ββββββββββββ
β BLPOP β BLPOP β BLPOP
βΌ βΌ βΌ
ββββββββββββββ ββββββββββββββ ββββββββββββββ
β Worker #1 β β Worker #2 β β Worker #3 β
β goroutine β β goroutine β β goroutine β
βββββββ¬βββββββ βββββββ¬βββββββ βββββββ¬βββββββ
ββββββββββββββββββββββ΄βββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββ
β GET /metrics β
β Real-time Dashboard β
βββββββββββββββββββββββββββ
| Feature | Details |
|---|---|
| β‘ Instant response | API returns in <5ms β never blocks on background work |
| π Concurrent workers | 3 goroutines process jobs in parallel |
| π Retry mechanism | Configurable retries per job on failure |
| π Real-time dashboard | Live metrics, job history, worker status β polls every 3s |
| π§© Modular tasks | Add a new job type by writing one case block |
| π‘οΈ Race-condition safe | sync/atomic counters across goroutines |
| π Structured logging | Every job logged with worker ID, type, and result |
| π Production deployed | Live on Render + Vercel, always-on via UptimeRobot |
| Layer | Technology | Role |
|---|---|---|
| Backend | Go 1.23 | Producer & Worker services |
| Queue | Redis (Upstash) | FIFO task queue via RPUSH / BLPOP |
| Concurrency | Goroutines + sync/atomic | Parallel job processing, race-safe counters |
| Frontend | HTML / CSS / JS | Real-time admin dashboard |
| Backend Deploy | Render | Producer & Worker hosting |
| Frontend Deploy | Vercel | Dashboard hosting |
POST https://workqueue-producer-kz2k.onrender.com/enqueue
Content-Type: application/json{
"type": "send_email",
"retries": 3,
"payload": {
"to": "test@gmail.com",
"subject": "Hello from WorkQueue π"
}
}Response
Task of type 'send_email' has been successfully added to the queue
GET https://distributed-task-processing-system-worker.onrender.com/metricsResponse
{
"total_jobs_in_queue": 2,
"jobs_done": 10,
"jobs_failed": 1
}| Type | Required Payload Fields | Processing Time |
|---|---|---|
send_email |
to, subject |
~2s |
resize_image |
new_x, new_y |
~1s |
generate_pdf |
title (optional) |
~3s |
Open cmd/worker/main.go and add one case:
switch t.Type {
case "send_email":
// existing...
case "your_new_task": // β just add this
// your logic here
return nil
}That's it. No config changes, no restarts.
- Go 1.21+
- Redis running locally
# 1. Clone
git clone https://github.com/thisisanubhav/distributed-task-processing-system.git
cd distributed-task-processing-system
# 2. Create config
cp config.env.example config.env
# Edit config.env β set your Redis URL and ports
# 3. Start Redis (macOS)
brew services start redis
# 4. Run Producer (Terminal 1)
go run cmd/producer/main.go
# 5. Run Worker (Terminal 2)
go run cmd/worker/main.go
# 6. Open dashboard
open frontend/index.htmlcurl -X POST http://localhost:8080/enqueue \
-H "Content-Type: application/json" \
-d '{
"type": "send_email",
"retries": 3,
"payload": {
"to": "test@example.com",
"subject": "Hello from WorkQueue!"
}
}'Why RPUSH + BLPOP?
RPUSH adds to the tail, BLPOP pops from the head β correct FIFO ordering. BLPOP with timeout 0 blocks until a job arrives, so workers use zero CPU while idle. No polling loop needed.
Why sync/atomic instead of a mutex?
Three goroutines concurrently increment jobs_done and jobs_failed. atomic.AddInt64 is a single CPU instruction β faster and simpler than acquiring a lock.
Why two separate services? Independent scaling. If the queue backs up, you deploy more workers without touching the producer. This mirrors how production systems like Sidekiq and Celery work.
Why goroutines over OS threads? Go goroutines start at ~2KB of stack vs ~8MB for OS threads. You can run thousands simultaneously with negligible overhead.
Time β 0s 1s 2s 3s 4s
β β β β β
Worker 1 [send_emailββββββββββ] [generate_pdfββββββββ]
Worker 2 [resize_imageβββ] [send_emailββββββββββ]
Worker 3 [generate_pdfββββββββββββββββββ] [resize_imageβββ]
Three jobs that would take 6s sequentially complete in ~3s in parallel.
Production job queue systems exist in every major language. Here's how this project maps to the ecosystem β and why I built it from scratch instead of using one of them.
| This Project | Celery (Python) | BullMQ (Node.js) | Sidekiq (Ruby) | Asynq (Go) | |
|---|---|---|---|---|---|
| Language | Go | Python | JavaScript | Ruby | Go |
| Queue backend | Redis | Redis / RabbitMQ | Redis | Redis | Redis |
| Concurrency model | Goroutines | Multiprocessing | Worker threads | Threads | Goroutines |
| Worker startup | ~5ms | ~500ms | ~100ms | ~200ms | ~5ms |
| Memory per worker | ~2KB | ~50MB | ~30MB | ~20MB | ~2KB |
Why build it instead of using an existing library?
Using Celery or BullMQ would have taken 30 minutes. Building it from scratch took considerably longer β and that's the point. Writing the queue logic by hand forced me to actually understand why BLPOP beats a polling loop, what a data race looks like when three goroutines share a counter, and how FIFO ordering breaks if you mix LPUSH and BLPOP. You don't learn those things by calling queue.add('send_email', payload).
Building this project from scratch β rather than using an existing library like Celery or BullMQ β forced me to confront problems I would never have encountered otherwise. Here's what genuinely changed how I think about software:
Concurrency is not parallelism, and both are hard.
I thought "just use goroutines" was the whole story. Then I ran the Go race detector (go run -race) on my first version and watched it flag jobs_done++ as a data race. Three goroutines were incrementing the same integer at the same time, and the result was silently wrong. Learning the difference between a mutex (which serialises access) and sync/atomic (which uses a single CPU instruction) was a turning point β not just for this project, but for how I think about any shared state.
Blocking is a feature, not a bug.
My first worker implementation used a polling loop β check Redis every 500ms, sleep, repeat. It worked but wasted CPU doing nothing useful. Switching to BLPOP with timeout 0 was eye-opening: the goroutine literally suspends itself at the OS level and wakes up the instant a job arrives, using zero CPU in between. Understanding why this works β the operating system's blocking I/O model β made distributed systems feel less magical.
Distributed systems fail in ways local programs don't.
When both services run on your laptop, everything works. The moment you deploy to Render, you discover: what happens when the Worker starts before Redis is ready? What if the Producer accepts a request but Redis is temporarily unreachable? I had to add startup health checks (rdb.Ping), proper error propagation, and CORS headers β none of which exist in a local-only project. These aren't afterthoughts; they're the actual job of backend engineering.
Separation of concerns scales. Running the Producer and Worker as two independent services initially felt like unnecessary complexity for a small project. But when I needed to debug the worker without restarting the API, or think about scaling workers independently, the separation paid off immediately. I now instinctively think about services in terms of their single responsibility and what would need to change if load on one component grew 10Γ.
Good tooling reveals bad code. The Go compiler refusing to compile unused variables, the race detector finding concurrent writes, the linter flagging swallowed errors β these weren't annoyances. They were the compiler teaching me things. Every flag was a real bug I'd written.
Anubhav Harsh Sinha
Found this useful? Drop a β β it helps others find the project.