Skip to content

Latest commit

Β 

History

32 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

⚑ Distributed Task Processing System

A production-grade background job queue built with Go and Redis

Producer Β· Worker Β· Redis Queue Β· Real-time Dashboard Β· Cloud Deployed

Go Redis Render Vercel License


🌐 Dashboard πŸ“‘ Producer API πŸ“Š Worker Metrics
distributed-task-processing-system.vercel.app workqueue-producer-kz2k.onrender.com worker.onrender.com/metrics

🧠 The Problem β€” A Real Example

Imagine you're building a food delivery app. A user places an order and hits "Place Order".

Behind the scenes, your server needs to:

  1. Save the order to the database
  2. Send a confirmation email to the user
  3. Send an SMS notification
  4. Notify the restaurant
  5. 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.


βœ… How This System Solves It

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).


πŸ—οΈ Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      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    β”‚
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

✨ Features

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

βš™οΈ Tech Stack

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

πŸ“‘ API Reference

Enqueue a task

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 live metrics

GET https://distributed-task-processing-system-worker.onrender.com/metrics

Response

{
  "total_jobs_in_queue": 2,
  "jobs_done": 10,
  "jobs_failed": 1
}

🧩 Supported Task Types

Type Required Payload Fields Processing Time
send_email to, subject ~2s
resize_image new_x, new_y ~1s
generate_pdf title (optional) ~3s

Adding a new task type

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.


πŸš€ Run Locally

Prerequisites

  • Go 1.21+
  • Redis running locally

Setup

# 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.html

Test it with curl

curl -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!"
    }
  }'

πŸ”‘ Key Engineering Decisions

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.


πŸ“Š Concurrency in action

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.


πŸ†š Compared to Alternatives

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).


πŸŽ“ What I Learned

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.


πŸ‘¨β€πŸ’» Author

Anubhav Harsh Sinha

LinkedIn GitHub


Found this useful? Drop a ⭐ β€” it helps others find the project.

About

A scalable distributed task processing system built with Go and Redis, enabling asynchronous job execution with concurrent workers and a real-time monitoring dashboard.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages