Skip to content
← Background Jobs · intermediate · 10 min · 04 / 07

Idempotency

Design jobs so running them twice is the same as running them once — because at-least-once delivery guarantees you will run them twice.

idempotencyat-least-onceexactly-oncededuplicationdistributed systems

Real-World Analogy

An elevator button: pressing it once summons the elevator. Pressing it five more times does nothing extra — the elevator still comes exactly once. That’s idempotency: repeated application of an operation produces the same result as applying it once.

At-Least-Once Delivery

Most queue systems guarantee at-least-once delivery — a job will run at least once, but might run more. This happens when:

  • A worker processes a job and crashes before acknowledging completion
  • The queue’s heartbeat times out during a long-running job
  • Network partition causes the queue to redeliver a job the worker already processed
  • A bug causes the queue to retry a job that actually succeeded

You cannot prevent this in a distributed system without significant coordination overhead. The pragmatic approach: accept at-least-once delivery and write idempotent handlers.

Exactly-once delivery is theoretically possible but expensive — it requires distributed transactions across the queue and your application state, with significant performance cost. For most use cases, at-least-once + idempotency is the right trade.

Making Operations Idempotent

The pattern: check if the work was already done before doing it, or use the database’s conflict handling to prevent duplicates.

Pattern 1: Existence check

async function sendWelcomeEmail(job: Job<{ userId: string }>): Promise<void> {
  const { userId } = job.data;

  // Check if already sent
  const alreadySent = await db.emailLog.findOne({
    where: { type: 'welcome', userId },
  });

  if (alreadySent) {
    logger.info({ userId, jobId: job.id }, 'Welcome email already sent, skipping');
    return; // idempotent — do nothing
  }

  await sendEmail(userId, 'welcome');

  // Record that we sent it
  await db.emailLog.insert({ type: 'welcome', userId, sentAt: new Date() });
}

Pattern 2: Database upsert

async function updateSearchIndex(job: Job<{ productId: string }>): Promise<void> {
  const product = await db.products.findById(job.data.productId);
  if (!product) return; // deleted since job was enqueued — OK to skip

  await searchIndex.upsert({
    id: product.id,          // upsert by ID — safe to run multiple times
    name: product.name,
    price: product.price,
    updatedAt: product.updatedAt,
  });
}

Pattern 3: Idempotency keys with external APIs

async function chargeCustomer(job: Job<{ orderId: string; amount: number }>): Promise<void> {
  const { orderId, amount } = job.data;

  await stripe.paymentIntents.create(
    {
      amount,
      currency: 'usd',
      customer: job.data.customerId,
      metadata: { orderId },
    },
    {
      // Stripe deduplicates requests with the same idempotency key
      // Job ID is stable across retries — safe to use
      idempotencyKey: `order-charge-${orderId}`,
    },
  );
}

Most well-designed APIs accept idempotency keys — Stripe, Twilio, Braintree, and many others. Check the docs before assuming an API is safe to call multiple times.

Deduplication at Enqueue Time

Prevent the same logical job from being added to the queue multiple times:

// BullMQ — jobId as deduplication key
await queue.add(
  'send-welcome-email',
  { userId: user.id },
  {
    jobId: `welcome-${user.id}`, // if this job already exists in queue, skip
  },
);

// pg-boss — singleton key
await boss.sendOnce(
  'send-welcome-email',
  { userId: user.id },
  {},
  `welcome-${user.id}`, // deduplication key
);

This prevents the queue from accumulating duplicate jobs when the enqueue operation itself is retried (e.g., if your API handler runs twice due to a client retry).

The Job ID as Stable Idempotency Key

Job IDs are stable across retries — the same job object is presented to the worker on each attempt. Use the job ID to track work done:

async function generateReport(job: Job<{ reportId: string }>): Promise<void> {
  const { reportId } = job.data;

  // Use job.id (not reportId) as idempotency key for external operations
  // This way, if the same reportId is queued twice (two different jobs),
  // each job handles its own idempotency independently

  const lockKey = `report-generation:${job.id}`;

  // Distributed lock: only one worker handles this job
  const locked = await redis.set(lockKey, '1', 'NX', 'EX', 300);
  if (!locked) {
    // Another worker is already processing this job — skip
    return;
  }

  try {
    // Check if already completed (in case of crash after completion)
    const existing = await db.reports.findOne({
      where: { jobId: job.id },
    });

    if (existing) return; // already done

    const data = await gatherReportData(reportId);
    const pdf = await renderPdf(data);

    await db.reports.insert({
      reportId,
      jobId: job.id, // link to job for dedup
      url: await uploadToS3(pdf),
      createdAt: new Date(),
    });
  } finally {
    await redis.del(lockKey);
  }
}

Non-Idempotent Operations and Fencing

Some operations are inherently non-idempotent (email sends with no dedup API, webhook deliveries). Use a fencing token to prevent duplicate execution:

interface JobFence {
  jobId: string;
  startedAt: Date;
  completedAt?: Date;
  result?: unknown;
}

async function withFence<T>(
  jobId: string,
  work: () => Promise<T>,
): Promise<T | null> {
  // Try to claim exclusive execution right
  const inserted = await db.jobFences.insertIfNotExists({
    jobId,
    startedAt: new Date(),
  });

  if (!inserted) {
    // Another worker already started (or completed) this job
    const existing = await db.jobFences.findByJobId(jobId);
    if (existing?.completedAt) {
      return existing.result as T; // already done, return cached result
    }

    // In-progress elsewhere — skip
    logger.warn({ jobId }, 'Job already in progress, skipping');
    return null;
  }

  try {
    const result = await work();
    await db.jobFences.update(jobId, { completedAt: new Date(), result });
    return result;
  } catch (err) {
    await db.jobFences.delete(jobId); // release the fence on failure — allow retry
    throw err;
  }
}

// Usage
async function sendNotificationEmail(job: Job): Promise<void> {
  await withFence(job.id, async () => {
    await emailProvider.send({
      to: job.data.email,
      subject: job.data.subject,
      body: job.data.body,
    });
  });
}

Testing for Idempotency

Make idempotency part of your test suite:

describe('sendWelcomeEmail job', () => {
  it('sends email exactly once when job runs twice', async () => {
    const emailSpy = jest.spyOn(emailService, 'send');
    const job = createMockJob({ userId: 'u_123' });

    // Run twice — simulates retry
    await sendWelcomeEmailHandler(job);
    await sendWelcomeEmailHandler(job);

    // Email sent exactly once
    expect(emailSpy).toHaveBeenCalledTimes(1);
  });

  it('handles already-completed job gracefully', async () => {
    // Pre-insert the email log record
    await db.emailLog.insert({ type: 'welcome', userId: 'u_123' });

    const job = createMockJob({ userId: 'u_123' });

    // Should not throw, should not send
    await expect(sendWelcomeEmailHandler(job)).resolves.not.toThrow();
    expect(emailService.send).not.toHaveBeenCalled();
  });
});

Run these tests against a real database (not mocks) — idempotency logic often involves upserts and conflict handling that only works with real SQL semantics.