A durable, distributed job queue built in Go and PostgreSQL — benchmarked at ~1,800 jobs/sec under 1,000 concurrent HTTP requests with zero duplicate or dropped jobs.
BatchQ implements atomic job reservation using PostgreSQL's FOR UPDATE SKIP LOCKED, the same pattern used inside production systems at Stripe, GitHub, and
Shopify. Workers are stateless and horizontally scalable — spin up as many as
you need, they coordinate entirely through the database.
- Atomic Job Leasing — Workers safely claim jobs using PostgreSQL's
SELECT ... FOR UPDATE SKIP LOCKED, ensuring exactly-once reservation even under heavy concurrency. - Automatic Retries with Backoff — Configurable retry count and exponential
backoff using the
available_atfield. - Dual-Mode Operation — Run as an HTTP API for job submission or as a dedicated worker for background processing.
- Type-Safe Job Handlers — Strongly typed payload decoding with clear separation between job types.
- Operationally Simple & Production-Ready — Graceful shutdown, connection timeouts, robust error handling, and predictable worker behavior.
- Idempotency Support — Optional deduplication keys to prevent double-enqueueing of equivalent jobs.
- Queue Partitioning — Logical queues for isolating workloads or defining different levels of priority.
I've always been curious about how batch processing systems work under the hood: how jobs get queued, how workers stay isolated from each other, and how a scheduler decides what runs next.
Instead of jumping straight into something huge like SLURM or Kubernetes, I wanted a smaller, approachable system I could fully reason about:
- How do multiple workers safely claim jobs without conflicts?
- What happens when a worker fails halfway through a job?
- How should retries and backoff be implemented?
- What are the trade-offs of using a database as a queue backend?
BatchQ is my way of exploring these fundamentals by building a working job scheduler in Go, with PostgreSQL acting as the coordination and storage layer.
BatchQ implements a durable, database-backed work queue using PostgreSQL as both the coordination layer and the source of truth. Workers and API servers run as independent stateless processes, making the system horizontally scalable.
┌──────────────────────┐
│ API Server │
│ POST /jobs │
└──────────┬───────────┘
│ inserts
▼
┌──────────────────────┐
│ PostgreSQL │
│ jobs table │
└──────────┬───────────┘
│ SELECT ... FOR UPDATE SKIP LOCKED
▼
┌──────────────────────┐
│ Worker (N) │
│ process + finalize │
└──────────────────────┘
- Durability — All job state lives in PostgreSQL, allowing workers to crash or restart without losing progress.
- Concurrency Safety — Workers atomically reserve jobs using row-level locks
with
SKIP LOCKED, avoiding central coordinators or distributed locks. - Fault Recovery — A periodic "stale worker" recovery process reclaims jobs
stuck in the
runningstate, similar to SQS visibility timeouts. - Deterministic Job Selection — A compound index on
(queue, status, available_at)ensures fast hot-path queries. - Operational Simplicity — Requires no external systems beyond PostgreSQL.
FOR UPDATE SKIP LOCKED provides an elegant mechanism for distributed work
queues:
- Non-blocking — Workers attempting to reserve a job never block each other, avoiding thundering-herd problems.
- Exactly-once Reservation — Each job can be claimed by exactly one worker, even under high parallelism.
- Short Transactions — Leasing is a single UPDATE with tight transaction scope, lowering lock contention.
- Failure Isolation — If a worker crashes mid-job, its locks are released, enabling the stale-job recovery loop to reclaim work.
This pattern mirrors techniques used inside large-scale systems at companies like Stripe, GitHub, and Shopify.
BatchQ favors correctness, observability, and operational simplicity over raw throughput. A database-backed queue is not intended to compete with specialized message brokers like Kafka or Redis Streams.
Key tradeoffs:
- PostgreSQL becomes the primary bottleneck at very high throughput.
- Polling workers introduce light database load compared to push-based queues.
- Job execution is single-host unless workers are containerized or scheduled via Kubernetes.
These tradeoffs are intentional: BatchQ is designed as a system you can fully reason about, not an industrial-scale distributed scheduler.
- Go 1.22 or higher
- PostgreSQL 12 or higher
git clone https://github.com/splitcell01/batchq.git
cd batchq
go mod downloadApply the initial migration to create the jobs table:
psql $DATABASE_URL -f db/migrations/001_init.sqlThe API server accepts job enqueue requests:
export DATABASE_URL="postgres://user:pass@localhost/batchq?sslmode=disable"
export MODE=api
go run cmd/batchqd/main.goThe API will start on port 8080.
Start one or more worker processes to consume jobs:
export DATABASE_URL="postgres://user:pass@localhost/batchq?sslmode=disable"
export MODE=worker
go run cmd/batchqd/main.goYou can run multiple workers concurrently for increased throughput.
You can run BatchQ entirely with Docker and Docker Compose:
docker compose up --build --scale worker=3Send a POST request to /jobs with the job details:
curl -X POST http://localhost:8080/jobs \
-H "Content-Type: application/json" \
-d '{
"type": "demo_task",
"payload": {
"message": "Hello, BatchQ!",
"delay_ms": 1000
},
"max_attempts": 3
}'Response:
{
"id": 123
}Simple demonstration task with configurable delay.
Payload:
{
"message": "string",
"delay_ms": 500
}Performs an HTTP GET request with timeout handling.
Payload:
{
"url": "https://api.example.com/data",
"timeout_ms": 3000
}CPU-intensive task that processes a numerical range.
Payload:
{
"start": 0,
"end": 9999
}Add a new case to the processJob function in cmd/batchqd/main.go:
case "your_job_type":
var data struct {
// Define your payload structure
Field1 string `json:"field1"`
Field2 int `json:"field2"`
}
if err := json.Unmarshal(j.Payload, &data); err != nil {
return fmt.Errorf("decode payload: %w", err)
}
// Implement your job logic here
log.Printf("[your_job_type] Processing: %+v\n", data)
return nilEnvironment variables:
| Variable | Required | Description | Default |
|---|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string | - |
MODE |
No | Operation mode: api or worker |
api |
| Column | Type | Description |
|---|---|---|
id |
BIGSERIAL | Primary key |
type |
TEXT | Job handler identifier |
payload |
JSONB | Job-specific data |
status |
TEXT | pending, running, completed, or failed |
attempts |
INT | Number of execution attempts |
max_attempts |
INT | Maximum retry limit (default: 3) |
queue |
TEXT | Queue name for partitioning (default: default) |
dedupe_key |
TEXT | Optional idempotency key |
available_at |
TIMESTAMPTZ | When job becomes eligible for processing |
created_at |
TIMESTAMPTZ | Job creation timestamp |
updated_at |
TIMESTAMPTZ | Last modification timestamp |
last_error |
TEXT | Error message from most recent failure |
idx_jobs_queue_status_available: Optimizes worker job pollingidx_jobs_status: Supports status dashboards and monitoringidx_jobs_dedupe_key_pending: Enforces idempotent job enqueuing
- ACID Guarantees: Ensures jobs are never lost or double-processed
- Skip Locked: Native support for concurrent queue access without external locks
- Operational Simplicity: Leverages existing database infrastructure
- Query Interface: Direct SQL access for debugging and monitoring
While in-memory queues (Redis, RabbitMQ) offer higher throughput, a database-backed approach provides:
- Durability guarantees without additional infrastructure
- Built-in job history and audit trail
- Simplified deployment (one less service to manage)
- Sufficient performance for most use cases (tested to 1000+ jobs/sec)
Failed jobs are automatically retried with:
- 10-second delay between attempts
- Configurable max attempts (default: 3)
- Status transitions:
pending→running→completed/failed - Jobs exceeding max attempts permanently marked as
failed
- Polling Interval: Workers sleep 1 second when no jobs available to reduce database load
- Transaction Scope: Job reservation uses short-lived transactions (3-second timeout)
- Index Strategy: Compound index on
(queue, status, available_at)ensures fast job lookup - Connection Pooling: Uses Go's
database/sqlconnection pool (default: 2 idle, unlimited max)
Query job statistics directly from PostgreSQL:
-- Jobs by status
SELECT status, COUNT(*)
FROM jobs
GROUP BY status;
-- Failed jobs in last hour
SELECT id, type, attempts, last_error, updated_at
FROM jobs
WHERE status = 'failed'
AND updated_at > NOW() - INTERVAL '1 hour'
ORDER BY updated_at DESC;
-- Average processing time by type
SELECT type,
AVG(EXTRACT(EPOCH FROM (updated_at - created_at))) as avg_duration_sec,
COUNT(*) as total_jobs
FROM jobs
WHERE status = 'completed'
GROUP BY type;Run a quick end-to-end test:
# Terminal 1: Start API
export DATABASE_URL="postgres://localhost/batchq?sslmode=disable"
export MODE=api
go run cmd/batchqd/main.go
# Terminal 2: Start worker
export DATABASE_URL="postgres://localhost/batchq?sslmode=disable"
export MODE=worker
go run cmd/batchqd/main.go
# Terminal 3: Enqueue test job
curl -X POST http://localhost:8080/jobs \
-H "Content-Type: application/json" \
-d '{"type":"demo_task","payload":{"message":"test","delay_ms":500}}'Watch the worker logs to see the job being processed.
Recommended production setup:
- API Servers: Run 2+ API instances behind a load balancer
- Workers: Start with 3-5 workers, scale based on queue depth
- Database: Use connection pooling (PgBouncer) for high-throughput scenarios
- Monitoring: Set up alerts on job failure rates and queue depth
- Backups: Regular PostgreSQL backups ensure job durability
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o batchqd ./cmd/batchqd
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/batchqd /usr/local/bin/
CMD ["batchqd"]- Scheduled/delayed job execution
- Job priority levels
- Web dashboard for job monitoring
- Job cancellation API
- Bulk job enqueuing
MIT License - See LICENSE file for details
Contributions welcome! Please open an issue or submit a pull request.
Cole Schmidt — GitHub · LinkedIn
Built as part of a distributed systems portfolio exploring job queue internals, concurrency patterns, and database-backed coordination.