Prevent duplicate Oban jobs with `unique` worker options

almirsarajcic

almirsarajcic

3 hours ago

Multiple button clicks or retries can enqueue the same background job multiple times, causing duplicate charges, emails, or API calls. Oban’s built-in uniqueness prevents this without custom deduplication logic.

# ❌ WITHOUT uniqueness - Same job runs multiple times
defmodule MyApp.ChargeCustomerWorker do
  use Oban.Worker

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"customer_id" => customer_id}}) do
    charge_customer(customer_id)  # Charges 3 times if enqueued 3 times!
    {:ok, "Charged customer #{customer_id}"}
  end
end

# ✅ WITH uniqueness - Only first job runs within time window
defmodule MyApp.ChargeCustomerWorker do
  use Oban.Worker, unique: [period: 60]

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"customer_id" => customer_id}}) do
    charge_customer(customer_id)  # Runs once, duplicates discarded
    {:ok, "Charged customer #{customer_id}"}
  end
end

The unique: [period: 60] option tells Oban: “If a job with identical args already exists and was inserted in the last 60 seconds, discard this duplicate.” The period is in seconds.

How uniqueness works

When you enqueue multiple identical jobs quickly:

# Enqueue same job 3 times within 1 second
MyApp.ChargeCustomerWorker.new(%{customer_id: 123}) |> Oban.insert()  # ✅ Inserted
MyApp.ChargeCustomerWorker.new(%{customer_id: 123}) |> Oban.insert()  # ❌ Discarded
MyApp.ChargeCustomerWorker.new(%{customer_id: 123}) |> Oban.insert()  # ❌ Discarded

Only the first job gets inserted. The duplicates return {:ok, :discard} instead of creating new jobs.

Advanced uniqueness configuration

Fine-tune uniqueness checking with additional options:

defmodule MyApp.NotificationWorker do
  use Oban.Worker,
    unique: [
      period: 300,                                  # 5 minute window
      fields: [:args, :worker],                     # Match on args and worker type
      keys: [:user_id, :notification_type],         # Only these arg keys matter
      states: [:available, :scheduled, :executing]  # Check these job states
    ]

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"user_id" => user_id, "notification_type" => type}}) do
    send_notification(user_id, type)
    {:ok, "Sent #{type} notification to user #{user_id}"}
  end
end

Configuration breakdown:

  • period: 300 - Check for duplicates in the last 5 minutes
  • fields: [:args, :worker] - Match based on job arguments and worker module (default)
  • keys: [:user_id, :notification_type] - Only compare these specific arg keys (ignore other args)
  • states: [:available, :scheduled, :executing] - Check jobs in these states (excludes :completed)

Common use cases

Payment processing:

use Oban.Worker, unique: [period: 300]  # Prevent double charges within 5 minutes

Email notifications:

use Oban.Worker, unique: [period: 3600]  # One notification per hour max

API rate limiting:

use Oban.Worker, unique: [period: 60, keys: [:user_id]]  # One API call per user per minute

Pro tip: Set period based on how long duplicate jobs should be prevented. For payment processing, use shorter windows (60-300 seconds). For daily reports, use longer windows (86400 seconds = 24 hours).