A production-ready background job processing system built with Fastify, BullMQ, Prisma, and PostgreSQL.
| Layer | Technology |
|---|---|
| API Server | Fastify |
| Queue | BullMQ + Redis |
| Database | PostgreSQL + Prisma |
| Language | TypeScript |
| Logging | Pino |
POST /api/jobs
│
▼
Prisma (PostgreSQL) ← source of truth
│
▼
BullMQ Queue (Redis) ← processing trigger
│
▼
BullMQ Worker
├── concurrency: 5
├── retries: 5 (exponential backoff)
├── on success → status: completed
├── on retry → status: pending
└── on failure → status: failed + move to DLQ
- Node.js 18+
- PostgreSQL
- Redis (Docker recommended)
1. Install dependencies
npm install2. Start Redis
docker run -d --name redis -p 6379:6379 redis:alpine3. Configure environment
Create a .env file in the project root:
DATABASE_URL=postgresql://user:password@localhost:5432/jobqueue
REDIS_HOST=localhost
REDIS_PORT=6379
CORS_ORIGIN=http://localhost:5173
NODE_ENV=development4. Run database migrations
npx prisma migrate dev5. Start the server
npm run devYou should see:
BullMQ worker started { queue: 'job-queue', concurrency: 5 }
Redis connected { host: 'localhost', port: 6379 }
Server running at http://[::1]:3000
Base URL: http://localhost:3000
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/jobs |
Create a new job |
GET |
/api/jobs |
List jobs (filterable) |
GET |
/api/jobs/stats |
Get status counts |
GET |
/api/jobs/:id |
Get job by ID |
POST |
/api/jobs/:id/retry |
Manually retry a failed job |
PATCH |
/api/jobs/:id |
Update a job |
DELETE |
/api/jobs/:id |
Delete a job |
GET |
/health |
Health check |
POST /api/jobs
Content-Type: application/json
{
"type": "send-email",
"payload": {
"to": "user@example.com",
"subject": "Welcome"
}
}Response 201:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "send-email",
"payload": { "to": "user@example.com", "subject": "Welcome" },
"status": "pending",
"attempts": 0,
"nextRunAt": null,
"createdAt": "2026-05-04T10:00:00.000Z",
"updatedAt": "2026-05-04T10:00:00.000Z"
}GET /api/jobs?status=failed&type=send-email&limit=50&offset=0Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
status |
pending | processing | completed | failed |
— | Filter by status |
type |
string | — | Filter by job type |
limit |
number | 100 |
Max results (max 500) |
offset |
number | 0 |
Pagination offset |
GET /api/jobs/statsResponse 200:
{
"pending": 12,
"processing": 3,
"completed": 847,
"failed": 5
}POST /api/jobs/:id/retryResets status → pending, attempts → 0, re-enqueues in BullMQ.
Returns 400 if job is not in failed status.
pending → processing → completed
↓
failed (retry scheduled)
↓ (after max attempts)
failed (permanent) → DLQ
| Status | Description |
|---|---|
pending |
Waiting to be picked up by worker |
processing |
Currently being executed |
completed |
Finished successfully |
failed |
Exhausted all retry attempts |
- Concurrency: processes up to 5 jobs simultaneously
- Retries: up to 5 attempts per job
- Backoff: exponential — 2s, 4s, 8s, 16s, 32s between retries
- Dead Letter Queue: permanently failed jobs are moved to
job-dlqin Redis with full context (jobId, type, payload, reason, attempts, failedAt)
src/
├── server.ts # entry point
├── app.ts # Fastify app setup
├── config/
│ ├── env.ts # environment variables
│ ├── db.ts # Prisma client
│ ├── redis.ts # ioredis connection
│ ├── queue.ts # BullMQ job queue
│ └── dlq.ts # BullMQ dead letter queue
├── modules/
│ └── job/
│ ├── job.controller.ts
│ ├── job.service.ts
│ ├── job.route.ts
│ ├── job.schema.ts
│ └── job.types.ts
├── workers/
│ └── job.worker.ts # BullMQ worker
├── plugins/
│ └── cors.ts # CORS plugin
└── utils/
└── logger.ts # Pino logger
| Script | Description |
|---|---|
npm run dev |
Start with hot reload (ts-node-dev) |
npm run build |
Compile TypeScript to dist/ |
npm start |
Run compiled production build |
CORS is configured to allow http://localhost:5173 by default (Vite dev server).
Override via environment variable for production:
CORS_ORIGIN=https://yourdomain.comRecommended polling intervals for a dashboard:
/api/jobs/stats— every 3–5 seconds/api/jobs?status=processing— every 3–5 seconds/api/jobs?status=failed— every 10–30 seconds