About
Experience
Individual Project · Jan 2026
Published: January 2026
Modern products do not process every task in the request-response path. Expensive operations — emails, image transforms, AI tasks — are queued and executed asynchronously by workers. This project is a full implementation of that architecture.
Every serious production system eventually outgrows the naive request-response model. When a user uploads an image, you don't resize it inline — you queue it. When an order is placed, you don't send the receipt email synchronously — you queue it. When an AI task is triggered, you definitely don't block the HTTP thread — you queue it.
I wanted to understand this pattern end-to-end: not just use an off-the-shelf queue service, but build the full stack — producer API, Redis-backed queue, typed workers, real-time dashboard, and observability. This project is that implementation.
The system is a Turborepo monorepo with four apps and two shared packages. Each layer has a single, clear responsibility.
High-Level Architecture
═══════════════════════════════════════════════════════
Client (Dashboard / API Consumer)
│
│ HTTP + WebSocket
▼
Express API ──── Zod validation, rate-limit, auth
│
│ BullMQ enqueue
▼
Redis (BullMQ backend) ──── persistent job storage
│
│ BullMQ dequeue
▼
Workers (email / image / ai) ──── bounded concurrency
│
│ Prometheus /metrics
▼
Prometheus ──────────────────► Grafana dashboardsdistributed-job-queue/
├── apps/
│ ├── api/ # Express API · WebSocket · /metrics endpoint
│ ├── workers/ # Email · Image · AI worker processes
│ └── dashboard/ # Next.js monitoring dashboard (BFF pattern)
├── packages/
│ ├── queue-config/ # Shared Redis connection + queue declarations
│ └── metrics/ # Shared Prometheus metric registry
└── infra/
├── docker-compose.yml
├── prometheus.yml
└── grafana/ # Provisioned datasource + starter dashboardThe API is an Express app that acts as the job producer. It accepts requests, validates them with Zod, enqueues work into Redis via BullMQ, and exposes status endpoints so clients can poll job state.
Every job submission payload is validated with Zod schemas before enqueueing. This catches bad data at the edge so workers never receive malformed jobs. Optional API key authentication and express-rate-limit protect the endpoints from abuse.
The `packages/queue-config` package is a shared module imported by both the API and workers. This single source of truth ensures both sides use identical queue names, Redis connection options, and default job settings.
// packages/queue-config/src/queues.ts
export const defaultJobOptions: DefaultJobOptions = {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { count: 100, age: 86400 }, // retain 24 h
removeOnFail: { count: 200, age: 259200 }, // retain 72 h
};
export const queues = {
email: new Queue('email', { connection, defaultJobOptions }),
image: new Queue('image', { connection, defaultJobOptions }),
ai: new Queue('ai', { connection, defaultJobOptions }),
};Workers run as independent Node.js processes — separate from the API so they can be scaled horizontally without touching HTTP traffic. Each worker has:
The dashboard is a Next.js app using the BFF (Backend-For-Frontend) pattern — API routes on the server proxy to the Express API, so the browser never holds credentials.
The shared `packages/metrics` module exposes a single `prom-client` registry. Both the API and each worker import this package and register their counters/histograms/gauges against the same registry, then expose `/metrics` on separate ports.
# infra/prometheus.yml — scrape config
scrape_configs:
- job_name: api
static_configs:
- targets: ['api:3001']
- job_name: workers
static_configs:
- targets: ['worker-email:9101', 'worker-image:9102', 'worker-ai:9103']Grafana is provisioned automatically with a starter dashboard (`bullmq-overview.json`) that visualises throughput, average duration, queue depth, and active worker count.
Step 1 Client POSTs to /jobs/:queue
Step 2 API validates payload (Zod) + checks auth + rate-limit
Step 3 API calls queue.add() → BullMQ serialises job to Redis
Step 4 Worker poll loop picks up job (FIFO / priority)
Step 5 Worker calls job.updateProgress() at each stage
Step 6 BullMQ persists result or failure state in Redis
Step 7 API /jobs/:queue/:id returns current state to poller
Step 8 QueueEvents broadcasts event → WebSocket /live → dashboard
Step 9 Metrics scraped by Prometheus every 15 s → Grafana rendersThe `infra/docker-compose.yml` spins up the entire stack — Redis, API, all three workers, the dashboard, Prometheus, and Grafana — with a single command:
docker compose -f infra/docker-compose.yml up --build
# Then open:
# Dashboard → http://localhost:3000
# API → http://localhost:3001
# Prometheus → http://localhost:9090
# Grafana → http://localhost:3002 (admin / admin)Building this end-to-end forced me to think about concerns that tutorials gloss over:
GitHub: github.com/aniketghavte — source code available on request.
Built with Node.js, BullMQ, Redis, Next.js, Prometheus, and Grafana.