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.

  • namebackground-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

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.

~/.claude/skills/background-jobs/SKILL.md

--- name: background-jobs description: Designs and fixes background job systems with BullMQ, Celery, Sidekiq or similar queues - idempotent handlers, retries with exponential… license: MIT ---

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?

WorkChoice
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 compensationWorkflow engine (Temporal, Step Functions, Durable Functions) instead of chained jobs
Periodic maintenanceScheduler (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 a processed_jobs table 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 or sidekiq-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 typeBehavior
Transient (timeout, 5xx, 429, connection reset, lock contention)Retry with exponential backoff + jitter
Rate limited with Retry-AfterDelay 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, Celery rate_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 Queue recurring.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

  1. Unit-test the handler: run it twice with the same payload and assert one side effect.
  2. Simulate a transient failure (mock throws a timeout): the job retries with growing delays.
  3. Simulate a permanent failure: the job lands in the dead letter set after the expected attempts.
  4. Kill a worker mid-job (kill -TERM, then kill -9 in a second test): the job is retried and completes once.
  5. 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 131 lines · 6.0 KB
# 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.

esc