Async jobs — idempotency, retry strategy, DLQ, observability.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add Dev-Toolbelt/dev-team-agents --skill async-jobs --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Async Jobs?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/dev-toolbelt-async-jobs)More formats (shields.io, HTML) on the badges page.
---
name: async-jobs
description: Async jobs — idempotency, retry strategy, DLQ, observability.
---
## Detection Signals
- `queue`, `worker`, `job`, or `consumer` directories or files in the project
- Dependencies: `laravel/horizon`, `sidekiq`, `celery`, `bullmq`, `bee-queue`, `faktory`, `resque`, `delayed_job`
- Cloud queue services: `aws-sdk` with SQS usage, `@google-cloud/pubsub`, Azure Service Bus, RabbitMQ, Kafka
- `QUEUE_*` / `REDIS_QUEUE_*` / `SQS_*` env vars
- Cron/scheduler configs: `app/Console/Kernel.php`, `celerybeat`, `node-cron`, `whenever` gem
---
## When to Use a Job vs. Synchronous Processing
Use a background job when:
- The operation takes longer than the acceptable HTTP response time (~200ms threshold for user-facing actions)
- The operation involves external services that can be slow or unavailable (email, SMS, payment processor, webhooks)
- The operation can be retried independently if it fails (idempotent by nature or can be made so)
- Fan-out: one event triggers multiple independent downstream actions
Keep it synchronous when:
- The user needs the result to proceed (e.g., payment status before showing confirmation)
- The operation is fast and its failure should block the response (e.g., saving the primary record)
- Ordering guarantee is critical and queues add non-determinism
---
## Idempotency — Most Critical Rule
**Every job must be safe to run more than once with the same payload.**
Queues guarantee at-least-once delivery — a job will be retried on failure and may execute multiple times. A non-idempotent job causes duplicate records, double charges, or double notifications.
```python
# ❌ Non-idempotent — creates a duplicate on retry
def send_welcome_email(user_id: str):
user = User.find(user_id)
Email.send(to=user.email, template="welcome")
# ✅ Idempotent — checks state before acting
def send_welcome_email(user_id: str):
user = User.find(user_id)
if user.welcome_email_sent_at is not None:
return # already sent — safe to skip
Email.send(to=user.email, template="welcome")
user.update(welcome_email_sent_at=now())
```
**Idempotency patterns:**
- **State check**: read current state before acting; bail out if the action was already completed
- **Unique constraint**: use a database unique key on `(job_type, entity_id)` to prevent duplicate execution
- **Idempotency key**: pass a UUID with the job payload; store it as processed after first execution
---
## Payload Validation
Treat job payloads with the same rigor as HTTP input — validate before processing:
```ts
// ✅ Validate payload shape before using it
async handle(payload: unknown) {
const { orderId, userId } = validateOrderPayload(payload); // throws on invalid
const order = await Order.findOrFail(orderId);
// ...
}
```
**Rules:**
- Never assume payload fields are present and correctly typed — they may come from an old version of the job or be corrupted
- Validate required fields and types at the start of the handler, before any side effects
- If validation fails: fail permanently (do not retry) — retrying a malformed payload wastes queue capacity
---
## Retry Strategy
| Failure type | Action |
|---|---|
| Transient (network timeout, 503) | Retry with exponential backoff |
| Business rule failure (record not found yet) | Retry with delay |
| Validation failure (malformed payload) | Fail permanently — do not retry |
| Unexpected exception | Retry up to max attempts, then DLQ |
**Exponential backoff formula**: `base_delay * 2^attempt` with jitter — prevents thundering herd when many jobs fail simultaneously.
**Minimum retry baseline** — unless the project defines otherwise: **3 attempts with exponential backoff starting at 2 s** (`2s → 4s → 8s`), then DLQ.
Configure per-job retry limits (not global defaults) — a payment job and an analytics job have different tolerance for retries. A payment job warrants fewer attempts and faster DLQ escalation than an analytics event.
---
## Dead Letter Queue (DLQ)
Every queue must have a DLQ configured. Jobs that exhaust retries move there instead of being silently dropped.
**After moving to DLQ:**
1. Alert the on-call team (do not let DLQ fill silently)
2. Inspect failed jobs to identify root cause
3. Fix the root cause, then replay or discard
**Never delete DLQ messages without inspecting them** — they contain the data of failed operations that may need manual resolution.
---
## Job Structure
```ts
// TypeScript / BullMQ example — same principles apply in any framework
class ProcessOrderJob {
static queue = 'orders';
static attempts = 3;
static backoff = { type: 'exponential', delay: 2000 };
async handle(payload: ProcessOrderPayload): Promise<void> {
// 1. Validate payload
const { orderId } = validateProcessOrderPayload(payload);
// 2. Idempotency check
const order = await Order.findOrFail(orderId);
if (order.status !== OrderStatus.PENDING) return;
// 3. Execute in a transaction if multiple writes are involved
await db.transaction(async (trx) => {
await order.process({ trx });
await Inventory.reserve(order.items, { trx });
});
// 4. Trigger downstream jobs (don't chain synchronously)
await SendOrderConfirmationJob.dispatch({ orderId });
}
}
```
---
## Observability
- **Log on start and completion** — include job ID, entity ID, and duration; useful for tracing stuck jobs
- **Never log sensitive payload fields** — mask emails, card numbers, passwords in logs
- **Emit metrics** — queue depth, job duration p95, failure rate; connect to the monitoring dashboard
- **Unique job ID in logs** — every log line inside a job handler must carry the job ID for correlation
```python
# ✅ Structured log with context
logger.info("order.processed", extra={
"job_id": self.request.id,
"order_id": order_id,
"duration_ms": elapsed,
})
```
---
## Framework-Specific Notes
| Framework | Key consideration |
|---|---|
| **Laravel Queue** | Use `ShouldBeUnique` for deduplication; `ShouldBeIdempotent` pattern via `uniqueId()`; Horizon for monitoring |
| **Sidekiq / ActiveJob** | Use `sidekiq-unique-jobs` for deduplication; set `retry` and `dead` thresholds per worker class |
| **Celery** | Set `acks_late=True` for at-least-once; use `task_id` for idempotency; Flower for monitoring |
| **BullMQ** | Use `jobId` option for deduplication; configure `removeOnComplete` to avoid memory bloat |
| **AWS SQS** | Visibility timeout must exceed max job duration; use `MessageDeduplicationId` on FIFO queues |
| **RabbitMQ** | Set `acks_late` / manual ack — never auto-ack before processing completes; configure a dead-letter exchange (`x-dead-letter-exchange`) per queue; use `x-message-ttl` to cap retry window; prefer quorum queues over classic for durability |
| **Apache Kafka** | Idempotency is consumer-side — commit offset only after successful processing (`enable.auto.commit=false`); use a unique `group.id` per consumer app; store processed offsets or business keys to detect replays; DLQ = a dedicated topic (e.g. `topic.DLT`); monitor consumer lag as the primary health signal |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!