Background Jobs skill for Claude Code
Jobs that survive duplicates, crashes and retries. Claude treats every queue as at-least-once delivery. It makes handlers idempotent with natural guards or unique records, enqueues only after the transaction commits, separates transient from permanent failures, and configures backoff, timeouts shorter than visibility windows, and dead-letter alerts so a crashed worker or flaky API never silently loses or doubles work.
- name
background-jobs - size132 lines · ≈1,700 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and fixes background job systems with BullMQ, Celery, Sidekiq or similar queues - idempotent handlers, retries with exponential backoff and jitter, dead-letter handling, enqueue-after-commit, timeouts and visibility windows, concurrency and rate limits, scheduled and cron jobs, graceful worker shutdown, and queue monitoring. Use when moving slow work out of requests, when jobs run twice, get lost, retry forever or pile up, or when adding scheduled tasks or a new worker type. Try asking
- Customers got charged twice. Make our charge job idempotent
- Move PDF generation out of the request into a BullMQ worker
- Our Celery tasks get redelivered while still running. Why?
Use it when
- Moving slow or failure-prone work out of HTTP requests
- Jobs run twice, vanish, retry forever or pile up
- Configuring retries, dead letters or worker concurrency
- Adding cron-style scheduled tasks to an app
Skip it when
- You are designing event schemas, outbox or sagas between services — use event-driven-architecture
- The workload is a batch data pipeline with backfills — use data-pipelines
How to check the result
Running a handler twice with the same payload yields one side effect, a simulated timeout retries with growing delays, and a permanent failure lands in the dead-letter set.
The common mistake it prevents
Enqueuing a job inside a database transaction, so workers start before the row commits or act on a write that rolled back.
Every queue delivers at least once. A correct job system assumes each job may run twice, partially, late, or out of order, and still produces the right result; every failure ends in a successful retry or a dead-letter entry someone will see.
Workflow
- [ ] 1. Identify the backend and broker (BullMQ+Redis, Celery+Redis/RabbitMQ/SQS, Sidekiq+Redis, Solid Queue, SQS)
- [ ] 2. Classify the job: side effect, retry safety, latency target, volume, ordering needs
- [ ] 3. Make the handler idempotent (decide the idempotency key)
- [ ] 4. Enqueue after the database transaction commits
- [ ] 5. Configure retries, backoff, timeouts, dead-letter destination
- [ ] 6. Set concurrency, rate limits, priorities per queue
- [ ] 7. Graceful shutdown, monitoring and alerting
- [ ] 8. Test: success, retryable failure, permanent failure, duplicate delivery
2. Should this be a job at all?
| Work | Choice |
|---|---|
| Must finish before responding (validation, the write itself) | Inline |
| Seconds of work the user waits for (export, report) | Job + status endpoint or push notification |
| Side effect after a write (email, webhook, search index) | Job enqueued after commit, or an outbox table relayed to the queue |
| Fire-and-forget, loss acceptable (analytics ping) | In-process background task is fine |
| Multi-step process over hours with compensation | Workflow engine (Temporal, Step Functions, Durable Functions) instead of chained jobs |
| Periodic maintenance | Scheduler (cron jobs in the queue system) with a lock so only one instance runs |
3. Idempotency
Pick the strategy per side effect:
- Natural idempotency:
UPDATE orders SET status='shipped' WHERE id=$1 AND status='paid'is safe to repeat; check rows affected. - Idempotency record: insert
(job_key)into aprocessed_jobstable with a unique constraint in the same transaction as the effect; a duplicate insert means “already done”. - Downstream idempotency keys: pass a stable key to payment and email APIs (e.g.
Stripe
Idempotency-Key: charge-order-123) so the provider dedupes. - Deduplicated enqueue: BullMQ custom
jobId, Sidekiq unique jobs (Enterprise orsidekiq-unique-jobs), Celery task IDs with a cache lock. This reduces duplicates but never replaces handler idempotency.
Pass IDs, not objects: { orderId: "ord_123" }. Load fresh state in the handler and
re-check preconditions (if order.charged: return). Payloads stay small (< 10 KB) and
JSON-serializable.
4. Enqueue after commit
A job enqueued inside a transaction can run before the commit (row not found) or after a
rollback (acts on nothing). Use the framework hook: Django transaction.on_commit, Rails
after_commit or Active Job’s enqueue-after-commit behavior, Laravel afterCommit(), or
write to an outbox table in the same transaction and relay it. For “row not found” in a
handler that was enqueued correctly, retry with backoff rather than failing permanently.
5. Retries, backoff, dead letters
| Failure type | Behavior |
|---|---|
| Transient (timeout, 5xx, 429, connection reset, lock contention) | Retry with exponential backoff + jitter |
Rate limited with Retry-After | Delay to that time; do not consume retry budget aggressively |
| Permanent (validation, 4xx except 408/429, missing record after N tries) | Fail immediately to dead letter; do not retry |
| Bug (exception in code) | Retry a few times, then dead letter; alert |
Defaults: 5 to 10 attempts, base delay 1-10 s, factor 2, cap at 1 h, full jitter
(delay = random(0, min(cap, base * 2**attempt))). Every job has a timeout shorter than
the broker’s visibility/lock window, or the job is redelivered while still running.
Dead letters: keep failed jobs with error, stack, attempt count and payload; alert on growth; provide a replay command. A dead-letter queue nobody watches equals data loss.
6. Concurrency and isolation
- Separate queues by latency class (
critical,default,bulk) with dedicated workers, so a 100k-row backfill cannot starve password-reset emails. - Concurrency per worker: I/O-bound jobs 10-50 (async/threads), CPU-bound jobs equal to cores (processes). Total DB connections = workers x concurrency; keep under pool limits.
- Rate-limit calls to third parties at the queue level (BullMQ
limiter, Celeryrate_limit, Sidekiq Enterprise limiters) rather than sleeping in handlers. - Ordering: queues do not guarantee order under concurrency or retries. If order matters per entity, use a version check in the handler, or partition by key (BullMQ Pro groups, SQS FIFO message groups, Kafka partitions).
7. Scheduling, shutdown, monitoring
- Cron/repeatable jobs: define in code (BullMQ
upsertJobScheduler, Celery beat schedule, sidekiq-cron or Sidekiq Enterprise periodic jobs, Solid Queuerecurring.yml). Run exactly one scheduler process; make the job itself safe to run twice. - Graceful shutdown: on SIGTERM stop fetching, finish or re-queue in-flight jobs within the orchestrator grace period. Long jobs checkpoint progress so a restart resumes rather than restarts.
- Monitor: queue depth, oldest-job age (the best single alert), throughput, failure rate, dead-letter size, and job duration p95. Dashboards: Bull Board, Flower, Sidekiq Web, Mission Control for Solid Queue.
Framework-specific configuration (BullMQ worker options, Celery acks_late and visibility_timeout, Sidekiq retry DSL, SQS redrive) is in references/framework-recipes.md. Read it when writing configuration for one of those backends.
Hard rules
- Never assume exactly-once. Design for duplicates.
- Never catch-and-log exceptions inside a handler; that turns failures into silent successes.
- Never enqueue inside a transaction without an after-commit mechanism.
- Every queue has a dead-letter path and an alert on oldest-job age.
- Large payloads go to object storage; the job carries a reference.
- Purging queues or deleting dead letters requires explicit user confirmation.
Verification
- Unit-test the handler: run it twice with the same payload and assert one side effect.
- Simulate a transient failure (mock throws a timeout): the job retries with growing delays.
- Simulate a permanent failure: the job lands in the dead letter set after the expected attempts.
- Kill a worker mid-job (
kill -TERM, thenkill -9in a second test): the job is retried and completes once. - Check the dashboard: queue depth returns to zero and oldest-job age stays under target.
Output contract
For a job design, document per job type:
| Job | Trigger | Idempotency key | Timeout | Attempts/backoff | Queue | DLQ + alert | Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/framework-recipes.md
# Framework recipes
Read only the section for the backend in use. Check installed versions first; option
names below match current major versions (BullMQ 5, Celery 5, Sidekiq 7/8).
## BullMQ (Node, Redis)
```ts
import { Queue, Worker, UnrecoverableError } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis(process.env.REDIS_URL!, { maxRetriesPerRequest: null }); // required for workers
export const emailQueue = new Queue("email", {
connection,
defaultJobOptions: {
attempts: 8,
backoff: { type: "exponential", delay: 2000 }, // 2s, 4s, 8s ...
removeOnComplete: { age: 24 * 3600, count: 10_000 },
removeOnFail: { age: 14 * 24 * 3600 }, // keep failures for inspection
},
});
await emailQueue.add("welcome", { userId }, { jobId: `welcome:${userId}` }); // dedupe while job exists
const worker = new Worker("email", async (job) => {
const user = await users.find(job.data.userId);
if (!user) throw new UnrecoverableError("user deleted"); // no further retries
await mailer.send(user.email, "welcome", { idempotencyKey: job.id });
}, { connection, concurrency: 20, limiter: { max: 50, duration: 1000 } });
worker.on("failed", (job, err) => log.error({ jobId: job?.id, err }, "job failed"));
process.on("SIGTERM", async () => { await worker.close(); process.exit(0); });
```
- Repeatable/cron jobs: `await queue.upsertJobScheduler("nightly-report", { pattern: "0 3 * * *" }, { name: "report", data: {} })`
(job schedulers replaced the older repeatable-jobs API in v5.16+).
- Use the Redis `noeviction` policy; an evicting Redis silently loses jobs.
- Stalled jobs: if a worker's event loop blocks longer than `lockDuration` (default 30 s),
the job is considered stalled and retried. Keep CPU work in sandboxed processors
(pass a file path to `Worker`) or raise `lockDuration`.
- Failed jobs past attempts remain in the `failed` set: that is the dead letter. Alert on
`await queue.getJobCounts("failed", "waiting", "delayed")`.
## Celery (Python)
```python
# celery_app.py
app = Celery("app", broker=os.environ["BROKER_URL"])
app.conf.update(
task_acks_late=True, # ack after success, so crashes redeliver
task_reject_on_worker_lost=True,
worker_prefetch_multiplier=1, # fair dispatch for long tasks
task_time_limit=300, # hard kill
task_soft_time_limit=270, # raises SoftTimeLimitExceeded first
broker_transport_options={"visibility_timeout": 3600}, # Redis/SQS: must exceed longest task + ETA
task_routes={"app.tasks.bulk_*": {"queue": "bulk"}},
)
@app.task(bind=True, autoretry_for=(requests.Timeout, requests.ConnectionError),
retry_backoff=True, retry_backoff_max=3600, retry_jitter=True, max_retries=8)
def send_receipt(self, order_id: str) -> None:
order = Order.objects.get(pk=order_id)
if order.receipt_sent_at:
return
mailer.send_receipt(order, idempotency_key=f"receipt-{order_id}")
Order.objects.filter(pk=order_id, receipt_sent_at__isnull=True).update(receipt_sent_at=now())
```
- `acks_late` + Redis/SQS means tasks exceeding `visibility_timeout` are redelivered to
another worker while still running. Countdown/ETA tasks also count against it.
- Beat: run exactly one `celery -A app beat` process; use `django-celery-beat` for
DB-managed schedules.
- Dead letters: Celery has no built-in DLQ for Redis. Record failures in an
`on_failure` handler or a `task_failure` signal into a table; with RabbitMQ configure
a dead-letter exchange on the queue.
- Start workers: `celery -A app worker -Q default,critical -c 8 --max-tasks-per-child 1000`.
- Shutdown: SIGTERM is a warm shutdown (finish current tasks). Set the container grace
period above `task_time_limit` or accept redelivery.
## Sidekiq (Ruby)
```ruby
class SyncInvoiceJob
include Sidekiq::Job
sidekiq_options queue: :default, retry: 10, dead: true
sidekiq_retry_in do |count, exception|
case exception
when Faraday::ClientError then :kill # permanent: straight to Dead set
else (count**4) + 15 + rand(10 * (count + 1)) # Sidekiq default curve with jitter
end
end
def perform(invoice_id)
invoice = Invoice.find_by(id: invoice_id) or return
return if invoice.synced?
Accounting.push(invoice, idempotency_key: "invoice-#{invoice_id}")
invoice.update!(synced_at: Time.current)
end
end
```
- Default 25 retries span about 20 days; lower it for user-facing work.
- Arguments must be simple JSON types (strings, numbers, arrays, hashes with string keys);
Sidekiq 7 warns and can raise on others (`Sidekiq.strict_args!`).
- Dead set (`retry` exhausted) is the DLQ; it is capped (10,000 jobs / 6 months by default).
- Concurrency in `config/sidekiq.yml` (`concurrency: 10`); weights per queue
(`queues: [[critical, 6], [default, 3], [bulk, 1]]`).
- Shutdown: Sidekiq stops fetching on TSTP/TERM and waits `timeout` (default 25 s) then
pushes unfinished jobs back to Redis.
## Amazon SQS + Lambda or workers
- Visibility timeout at least 6x the consumer timeout for Lambda event sources; for
long-running workers extend it with `ChangeMessageVisibility` while working.
- Redrive policy `maxReceiveCount` 3-5 to a DLQ; alarm on `ApproximateNumberOfMessagesVisible`
for the DLQ > 0 and on `ApproximateAgeOfOldestMessage` for the main queue.
- Batch consumers report partial failures (`ReportBatchItemFailures`) so one bad message
does not retry the whole batch.
- FIFO queues: ordering and dedupe per `MessageGroupId`, 5-minute dedupe window by
`MessageDeduplicationId`; throughput is per group.
- Replay DLQ messages with the console or `aws sqs start-message-move-task --source-arn <dlq-arn>`.
## Solid Queue (Rails 8)
- Configure workers and dispatchers in `config/queue.yml`, recurring tasks in
`config/recurring.yml`. Runs on the database; keep job tables in a separate database
(Rails 8 default `queue` database) to avoid contention.
- Failed executions stay in `solid_queue_failed_executions`; inspect and retry with
Mission Control Jobs.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.