Background Jobs
Purpose
Move slow, external, or deferrable work off the request path into a durable queue with proper retries, dead-lettering, and idempotent handlers — so failures are visible and recoverable, not silent.
Universal — queue design, retry/backoff, DLQ, concurrency control, and idempotent handlers are job-processing principles; BullMQ is the default implementation.
Procedure
Decide what belongs in a job
- Slow (> ~1s), external I/O, deferrable, or retryable work → job
- Keep the request fast; return early, do the work async
- Don't queue work that must be synchronous for the user's next action
Make every handler idempotent
- At-least-once execution means a job CAN run more than once (retry, redelivery)
- Dedupe on a job/business key; design the side effect to be safe to repeat (see
resilience-patterns idempotency)
Configure retries: exponential backoff + jitter, capped
- Set
attempts + backoff: { type: 'exponential' } (+ jitter)
- Distinguish retryable (timeout, 503) from non-retryable (validation, 4xx) failures — don't retry the un-retryable
Dead-letter queue for poison messages
- After max attempts, move to a DLQ instead of dropping or infinite-retrying
- Alert on DLQ growth; provide a replay path after the bug is fixed
Control concurrency — and apply backpressure
- Set worker concurrency to protect downstream (DB pool, rate-limited API)
- Rate-limit jobs hitting a quota'd external service
- Producers outpacing consumers → queue depth climbs. Decide the policy: producer slowdown / circuit-break the producing endpoint / shed (return 429 / drop low-priority). Unbounded queue depth is a delayed outage, not a working system
5b. Set a visibility timeout / heartbeat for long jobs
- A worker that dies mid-job leaves the job in "in progress" — without a visibility timeout it's stuck forever; with one (and an active heartbeat from healthy workers) the job becomes eligible for re-delivery
- Tune the timeout to slightly above the job's p99 duration; emit heartbeats for genuinely-long jobs
5c. Retain only what you need (job table cleanup)
- Completed and failed jobs accumulate — set
removeOnComplete (or equivalent) with a retention count/age; archive critical job history elsewhere if needed
- An unbounded job store eats Redis / DB memory silently
Make jobs observable
- Log job start/end/failure with correlation id (see
observability-setup)
- Track queue depth, processing latency, failure rate
Validate (validation loop)
- Force a job to fail → verify retry with backoff, then DLQ after cap (not infinite, not silent)
- Run a job twice → verify idempotent (no duplicate side effect)
- If a duplicate side effect occurs → handler not idempotent; fix and re-test
Anti-patterns
| ❌ Anti-pattern |
✅ Correct |
| Non-idempotent handler (retry double-charges) |
Idempotent handler (dedupe key) |
| Retry forever on any failure |
Capped attempts → DLQ; retry only transient errors |
| Failed jobs silently dropped |
DLQ + alert + replay path |
| Unbounded concurrency exhausting DB pool |
Tuned concurrency + rate limits |
| Blocking the request on slow work |
Enqueue, return fast |
| No visibility timeout / heartbeat → job stuck "in progress" forever on worker crash |
Visibility timeout slightly above p99; heartbeat for long jobs |
| Completed jobs accumulating in Redis until OOM |
removeOnComplete retention + archive critical history elsewhere |
| Producer faster than consumers; queue grows unbounded |
Backpressure policy (slow producer / 429 / shed low-priority) |
Severity tiers
| Tier |
Examples |
Action SLA |
| Critical |
Non-idempotent job double-charging on retry; failed payment jobs silently dropped |
Fix immediately |
| Major |
No DLQ (infinite retry or data loss); no backoff (retry storm) |
Fix this sprint |
| Minor |
Concurrency untuned; missing queue-depth metric |
Schedule within 2 sprints |
Completion Criteria
Output
- Job definitions + worker config: retry/backoff/concurrency/DLQ
- Idempotency keys per job type
- Commit format:
feat(jobs): add <job> with DLQ + backoff / fix(jobs): make <handler> idempotent
Implementation
TypeScript + BullMQ + Redis (default)
defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 1000 } } (+ jitter via custom strategy)
- DLQ: a separate queue; move on
failed after attempts exhausted; BullMQ failed events
- Concurrency:
new Worker(name, fn, { concurrency: N, limiter: { max, duration } })
- Idempotent: dedupe table on job key, or use
jobId for natural dedup
- See BullMQ "Going to Production"
Other stacks
- Python: Celery (
acks_late=True, max_retries, autoretry_for) + dead-letter via routing
- Go: Asynq or River — same retry/DLQ/concurrency model
- Universal: idempotency + capped backoff + DLQ are queue-agnostic; the at-least-once contract is the same everywhere
Related skills
async-messaging — the outbox relay and event consumers run as jobs
resilience-patterns — job retries reuse backoff + idempotency
webhook-design — received webhooks are processed as jobs
Reference
- Key insight encoded: Configure exponential backoff with jitter in
defaultJobOptions, cap attempts, and make handlers idempotent — at-least-once execution means a job can run more than once on retry.
1---2name: background-jobs3description: Run work off the request thread reliably — queue design, retries with exponential backoff + jitter, dead-letter queues, concurrency control, and idempotent handlers. Use when an operation is slow/external, when jobs fail silently, or when retries cause duplicates. Not for write+event transactional reliability — the dual-write problem (use async-messaging Outbox) or webhook-receiver specifics (use webhook-design).4license: MIT5---67# Background Jobs89## Purpose10Move slow, external, or deferrable work off the request path into a durable queue with proper retries, dead-lettering, and idempotent handlers — so failures are visible and recoverable, not silent.1112**Universal** — queue design, retry/backoff, DLQ, concurrency control, and idempotent handlers are job-processing principles; BullMQ is the default implementation.1314## Procedure15161. **Decide what belongs in a job**17 - Slow (> ~1s), external I/O, deferrable, or retryable work → job18 - Keep the request fast; return early, do the work async19 - Don't queue work that must be synchronous for the user's next action20212. **Make every handler idempotent**22 - At-least-once execution means a job CAN run more than once (retry, redelivery)23 - Dedupe on a job/business key; design the side effect to be safe to repeat (see `resilience-patterns` idempotency)24253. **Configure retries: exponential backoff + jitter, capped**26 - Set `attempts` + `backoff: { type: 'exponential' }` (+ jitter)27 - Distinguish retryable (timeout, 503) from non-retryable (validation, 4xx) failures — don't retry the un-retryable28294. **Dead-letter queue for poison messages**30 - After max attempts, move to a DLQ instead of dropping or infinite-retrying31 - Alert on DLQ growth; provide a replay path after the bug is fixed32335. **Control concurrency — and apply backpressure**34 - Set worker concurrency to protect downstream (DB pool, rate-limited API)35 - Rate-limit jobs hitting a quota'd external service36 - **Producers outpacing consumers** → queue depth climbs. Decide the policy: producer slowdown / circuit-break the producing endpoint / shed (return 429 / drop low-priority). Unbounded queue depth is a delayed outage, not a working system37385b. **Set a visibility timeout / heartbeat for long jobs**39 - A worker that dies mid-job leaves the job in "in progress" — without a visibility timeout it's stuck forever; with one (and an active heartbeat from healthy workers) the job becomes eligible for re-delivery40 - Tune the timeout to slightly above the job's p99 duration; emit heartbeats for genuinely-long jobs41425c. **Retain only what you need (job table cleanup)**43 - Completed and failed jobs accumulate — set `removeOnComplete` (or equivalent) with a retention count/age; archive critical job history elsewhere if needed44 - An unbounded job store eats Redis / DB memory silently45466. **Make jobs observable**47 - Log job start/end/failure with correlation id (see `observability-setup`)48 - Track queue depth, processing latency, failure rate49507. **Validate (validation loop)**51 - Force a job to fail → verify retry with backoff, then DLQ after cap (not infinite, not silent)52 - Run a job twice → verify idempotent (no duplicate side effect)53 - If a duplicate side effect occurs → handler not idempotent; fix and re-test5455## Anti-patterns5657| ❌ Anti-pattern | ✅ Correct |58|---|---|59| Non-idempotent handler (retry double-charges) | Idempotent handler (dedupe key) |60| Retry forever on any failure | Capped attempts → DLQ; retry only transient errors |61| Failed jobs silently dropped | DLQ + alert + replay path |62| Unbounded concurrency exhausting DB pool | Tuned concurrency + rate limits |63| Blocking the request on slow work | Enqueue, return fast |64| No visibility timeout / heartbeat → job stuck "in progress" forever on worker crash | Visibility timeout slightly above p99; heartbeat for long jobs |65| Completed jobs accumulating in Redis until OOM | `removeOnComplete` retention + archive critical history elsewhere |66| Producer faster than consumers; queue grows unbounded | Backpressure policy (slow producer / 429 / shed low-priority) |6768## Severity tiers6970| Tier | Examples | Action SLA |71|---|---|---|72| **Critical** | Non-idempotent job double-charging on retry; failed payment jobs silently dropped | Fix immediately |73| **Major** | No DLQ (infinite retry or data loss); no backoff (retry storm) | Fix this sprint |74| **Minor** | Concurrency untuned; missing queue-depth metric | Schedule within 2 sprints |7576## Completion Criteria77- [ ] Handlers idempotent (verified by double-run)78- [ ] Retries capped with exponential backoff + jitter79- [ ] DLQ configured + alerted80- [ ] Concurrency tuned to protect downstream81- [ ] Queue depth + failure rate observable8283## Output84- **Job definitions + worker config**: retry/backoff/concurrency/DLQ85- **Idempotency keys** per job type86- **Commit format**: `feat(jobs): add <job> with DLQ + backoff` / `fix(jobs): make <handler> idempotent`8788## Implementation8990### TypeScript + BullMQ + Redis (default)91- `defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 1000 } }` (+ jitter via custom strategy)92- DLQ: a separate queue; move on `failed` after attempts exhausted; BullMQ `failed` events93- Concurrency: `new Worker(name, fn, { concurrency: N, limiter: { max, duration } })`94- Idempotent: dedupe table on job key, or use `jobId` for natural dedup95- See [BullMQ "Going to Production"](https://docs.bullmq.io/guide/going-to-production)9697### Other stacks98- **Python**: Celery (`acks_late=True`, `max_retries`, `autoretry_for`) + dead-letter via routing99- **Go**: Asynq or River — same retry/DLQ/concurrency model100- **Universal**: idempotency + capped backoff + DLQ are queue-agnostic; the at-least-once contract is the same everywhere101102## Related skills103- `async-messaging` — the outbox relay and event consumers run as jobs104- `resilience-patterns` — job retries reuse backoff + idempotency105- `webhook-design` — received webhooks are processed as jobs106107## Reference108- **Key insight encoded**: Configure exponential backoff with jitter in `defaultJobOptions`, cap attempts, and make handlers idempotent — at-least-once execution means a job can run more than once on retry.