BullMQ delivers jobs at least once. If a worker crashes mid-execution, loses its Redis lock, or restarts, the job is redelivered and your handler runs again. Without idempotency guards, this causes duplicate emails, double charges, and phantom records.
Rotor gives you two independent layers of protection:
- Enqueue dedup — the same
jobIdis never enqueued twice. BullMQ silently discards the second submission. - Handler dedup — your handler checks a DB record before doing work, preventing double execution even when the same job is delivered more than once.
Use both layers. Enqueue dedup prevents the common case; handler dedup is the safety net for redeliveries that slip through.
Layer 1 — Explicit jobId at enqueue
Pass a deterministic jobId when enqueueing. BullMQ deduplicates at the queue level: submitting the same jobId again is a no-op.
import { Rotor } from "@rotorsh/sdk";
const rotor = new Rotor({ apiKey: process.env.ROTOR_API_KEY! });
const job = await rotor.jobs.enqueue("outreach", {
payload: { contactId: "cid_123", campaignId: "cmp_456" },
jobId: "outreach:cmp_456:cid_123", // deterministic key
});If outreach:cmp_456:cid_123 is already in the queue, BullMQ returns without creating a new job. No error is thrown; the call is silent.
Build your jobId from the combination of entities that must be unique: e.g.
{queue}:{campaignId}:{contactId}. This makes duplicate submissions safe
regardless of how they occur — retried HTTP requests, double-clicks, or
re-triggered workflows.
Batch with idempotency keys
For batch enqueue, pass an idempotencyKeys array alongside your jobs. The array length must match the jobs array length, and keys must be unique within the batch.
const contacts = [
{ id: "cid_001", email: "[email protected]" },
{ id: "cid_002", email: "[email protected]" },
{ id: "cid_003", email: "[email protected]" },
];
await rotor.jobs.addBatch(
"outreach",
contacts.map((c) => ({
payload: { contactId: c.id, email: c.email },
})),
{
idempotencyKeys: contacts.map((c) => `outreach:cmp_456:${c.id}`),
}
);Rules for idempotencyKeys:
- Length must equal the number of jobs in the batch
- No duplicates within a single batch call
- Cannot conflict with explicit
jobIdfields on individual jobs in the same call - Re-submitting the same key is a silent no-op — BullMQ deduplicates
Layer 2 — Handler-level dedup
Enqueue dedup prevents most duplicates. Handler dedup catches redeliveries: cases where the job was enqueued once but executed more than once (e.g. worker crash after partial execution).
The pattern: check your DB for rotorJobId before doing work; write it after.
import { RotorWorker } from "@rotorsh/sdk";
const worker = new RotorWorker({
workspaceId: process.env.WORKSPACE_ID!,
queueName: "outreach",
connection: process.env.REDIS_URL!,
concurrency: 5,
processor: async (job) => {
const { contactId } = job.data.payload;
// Check if this exact job execution already completed
const existing = await db.outreachSent.findUnique({
where: { rotorJobId: job.id },
});
if (existing) {
return { skipped: true, reason: "already-executed" };
}
// Do the work
await sendWelcomeEmail(contactId);
// Record completion — use upsert in case of a tight race
await db.outreachSent.upsert({
where: { rotorJobId: job.id },
create: { rotorJobId: job.id, contactId, sentAt: new Date() },
update: {}, // already exists — ignore
});
return { sent: true };
},
});
await worker.ready();Schema for the dedup table
CREATE TABLE outreach_sent (
id SERIAL PRIMARY KEY,
rotor_job_id TEXT NOT NULL,
contact_id TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (rotor_job_id) -- enforces dedup at DB level
);Or with a compound unique constraint to scope dedup per workspace:
UNIQUE (workspace_id, rotor_job_id)The Idempotency-Key header
When making HTTP API calls to Rotor directly (not via the SDK), include an Idempotency-Key header. The SDK parses this with parseIdempotencyKey:
import { parseIdempotencyKey } from "@rotorsh/sdk";
// In an API route handler
const idempotencyKey = parseIdempotencyKey(req.headers);
if (idempotencyKey) {
await rotor.jobs.enqueue("outreach", {
payload: { contactId },
jobId: idempotencyKey, // use the caller's key as the jobId
});
}This lets upstream callers (e.g. webhook consumers, frontend clients) pass their own idempotency keys through to Rotor.
RotorIdempotencyConflict
In strict mode, submitting a duplicate idempotency key throws RotorIdempotencyConflict (HTTP 409) instead of silently deduplicating. Handle it explicitly when you need to distinguish a genuine duplicate from a new submission:
import { Rotor, RotorIdempotencyConflict } from "@rotorsh/sdk";
const rotor = new Rotor({ apiKey: process.env.ROTOR_API_KEY! });
try {
await rotor.jobs.enqueue("outreach", {
payload: { contactId: "cid_123" },
jobId: "outreach:cmp_456:cid_123",
});
} catch (err) {
if (err instanceof RotorIdempotencyConflict) {
// A job with this key already exists
// Safe to treat as success — the job is already queued
console.log("Duplicate submission — job already enqueued");
return;
}
throw err;
}The 24-hour replay window
Rotor does not maintain an application-state dedup table for you. Handler dedup — the DB record you write after executing — is your responsibility.
A robust pattern is a 24-hour replay table: any rotorJobId processed in the last 24 hours is recorded and checked at handler entry. Clean up records older than 24 hours with a scheduled job or DB TTL.
// Check and record in one atomic upsert
const { created } = await db.jobReplay.upsert({
where: { rotorJobId: job.id },
create: { rotorJobId: job.id, processedAt: new Date() },
update: {}, // already exists — do nothing
select: { created: true }, // Prisma: true if INSERT, false if no-op
});
if (!created) {
return { skipped: true, reason: "replay-deduplicated" };
}
// Proceed with actual workThe replay table prevents double-execution when a job is redelivered within the retention window. Records older than your window can be pruned — Rotor guarantees at-least-once, not unbounded redelivery.
Practical example — welcome emails without double-sending
A new user signs up. Your app enqueues a welcome email job. If your worker crashes after calling the email API but before the job is marked complete, BullMQ redelivers and your handler runs again.
Without idempotency, the user gets two welcome emails. With two-layer defense:
import { Rotor, RotorWorker } from "@rotorsh/sdk";
const rotor = new Rotor({ apiKey: process.env.ROTOR_API_KEY! });
// At signup — deterministic jobId prevents double-enqueue
await rotor.jobs.enqueue("welcome-email", {
payload: { userId: user.id, email: user.email },
jobId: `welcome:${user.id}`, // one welcome email per userId, ever
});
// Worker — handler dedup prevents double-execution on redelivery
const worker = new RotorWorker({
workspaceId: process.env.WORKSPACE_ID!,
queueName: "welcome-email",
connection: process.env.REDIS_URL!,
concurrency: 10,
processor: async (job) => {
const { userId, email } = job.data.payload;
const alreadySent = await db.welcomeEmailLog.findUnique({
where: { rotorJobId: job.id },
});
if (alreadySent) {
return { skipped: true };
}
await emailClient.send({
to: email,
template: "welcome",
data: { userId },
});
await db.welcomeEmailLog.create({
data: { rotorJobId: job.id, userId, sentAt: new Date() },
});
return { sent: true, userId };
},
});
await worker.ready();Layer 1 (jobId: "welcome:{userId}") ensures a second signup event or retry of the enqueue call never creates a second job. Layer 2 (welcomeEmailLog check) ensures a redelivered job never sends a second email.