| name | oban-essentials |
| description | Use when writing background jobs with Oban — worker options, return contracts, idempotency, uniqueness, Oban.Testing. |
| file_patterns | ["**/workers/**/*.ex","**/*_worker.ex"] |
| auto_suggest | true |
Oban Essentials
RULES — Follow these with no exceptions
- Always
use Oban.Worker with explicit queue and max_attempts options
- Return
{:ok, result} for success, {:error, reason} for retryable failures, {:cancel, reason} for permanent failures — prefer {:ok, result} over bare :ok for explicit intent (bare :ok is supported), and prefer {:error, reason} over raising
- Make workers idempotent — the same job may run more than once due to retries or node restarts
- Use
unique option to prevent duplicate jobs — specify period, fields, and keys
- Test with
Oban.Testing — use assert_enqueued and perform_job, never call perform/1 directly
- Never put large data in job args — store IDs and fetch fresh data in the worker
- Use
Oban.insert/1 (not Oban.insert!/1) and handle the error tuple
Worker Definition
defmodule MyApp.Workers.SendWelcomeEmail do
use Oban.Worker,
queue: :mailers,
max_attempts: 3,
unique: [period: 300, fields: [:args], keys: [:user_id]]
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
case MyApp.Accounts.get_user(user_id) do
nil ->
{:cancel, "user #{user_id} not found"}
user ->
MyApp.Mailer.send_welcome(user)
{:ok, :sent}
end
end
end
Key Points
queue — which queue runs this worker (must match config)
max_attempts — total attempts including the first (3 = 1 original + 2 retries)
unique — deduplication; period in seconds, keys specifies which args fields to compare
- Pattern match on
%Oban.Job{args: ...} — args are always string-keyed maps (JSON serialized)
Enqueuing Jobs
# Basic insert — always handle the result
case MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert() do
{:ok, job} -> {:ok, job}
{:error, changeset} -> {:error, changeset}
end
# Schedule for later
%{user_id: user.id}
|> MyApp.Workers.SendWelcomeEmail.new(schedule_in: 3600)
|> Oban.insert()
# Bad — raises on failure, no error handling
MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert!()
Enqueuing from Contexts
Enqueue jobs from context modules, not LiveViews or controllers:
# Good — context handles the job and its insert result
defmodule MyApp.Accounts do
def register_user(attrs) do
with {:ok, user} <- create_user(attrs) do
case MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert() do
{:ok, _job} -> {:ok, user}
{:error, changeset} -> {:error, changeset}
end
end
end
end
# Bad — LiveView enqueues directly
def handle_event("register", params, socket) do
MyApp.Workers.SendWelcomeEmail.new(%{user_id: user.id}) |> Oban.insert()
end
Return Values
@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
# Success — job completed, marked as completed
{:ok, result}
# Retryable failure — will retry up to max_attempts
{:error, reason}
# Permanent failure — will NOT retry, marked as cancelled
{:cancel, reason}
# Snooze — reschedule for later (in seconds)
{:snooze, 60}
end
Prefer returning {:error, reason} over raising. Both are recorded as retryable failures, but tuples make retry intent explicit and avoid the noisy logs and stack traces that come with an unhandled exception.
Queue Configuration
# config/config.exs
config :my_app, Oban,
repo: MyApp.Repo,
queues: [
default: 10, # 10 concurrent jobs
mailers: 5, # 5 concurrent email jobs
imports: 2 # 2 concurrent import jobs (resource-heavy)
]
# config/test.exs
config :my_app, Oban, testing: :manual
# :manual keeps jobs in the queue so assert_enqueued/perform_job work.
# Use :inline only for tests that want jobs executed immediately in-process —
# assert_enqueued will always fail under :inline because jobs are never inserted.
Idempotency
Workers must be safe to run multiple times with the same args.
# Bad — sends duplicate emails on retry
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
user = MyApp.Accounts.get_user!(user_id)
MyApp.Mailer.send_welcome(user)
{:ok, :sent}
end
# Good — check if already processed
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
user = MyApp.Accounts.get_user!(user_id)
if user.welcome_email_sent_at do
{:ok, :already_sent}
else
with {:ok, _} <- MyApp.Mailer.send_welcome(user),
{:ok, _} <- MyApp.Accounts.mark_welcome_sent(user) do
{:ok, :sent}
end
end
end
Unique Jobs
Prevent duplicate jobs from being enqueued:
use Oban.Worker,
queue: :default,
unique: [
period: 300, # 5-minute uniqueness window
fields: [:args, :queue], # match on these fields
keys: [:user_id], # only compare these arg keys
states: [:available, :scheduled, :executing] # check these states
]
When to Use
- Email sending — don't send the same email twice within 5 minutes
- Data syncing — don't start a sync if one is already running
- Webhook delivery — deduplicate retry attempts
Scheduled and Recurring Jobs
# Schedule a job for later
%{report_id: report.id}
|> MyApp.Workers.GenerateReport.new(schedule_in: {1, :hour})
|> Oban.insert()
# Cron-based recurring jobs (in config)
config :my_app, Oban,
repo: MyApp.Repo,
queues: [default: 10],
plugins: [
{Oban.Plugins.Cron, crontab: [
{"0 2 * * *", MyApp.Workers.NightlyCleanup},
{"*/15 * * * *", MyApp.Workers.SyncData, args: %{source: "api"}}
]}
]
Pruning
Keep the jobs table from growing indefinitely:
config :my_app, Oban,
plugins: [
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7} # 7 days
]
Testing
# test/my_app/workers/send_welcome_email_test.exs
defmodule MyApp.Workers.SendWelcomeEmailTest do
use MyApp.DataCase, async: true
use Oban.Testing, repo: MyApp.Repo
alias MyApp.Workers.SendWelcomeEmail
test "enqueuing a welcome email job" do
user = user_fixture()
SendWelcomeEmail.new(%{user_id: user.id})
|> Oban.insert()
assert_enqueued(worker: SendWelcomeEmail, args: %{user_id: user.id})
end
test "performing the job sends the email" do
user = user_fixture()
assert {:ok, :sent} =
perform_job(SendWelcomeEmail, %{user_id: user.id})
end
test "cancels if user not found" do
assert {:cancel, _reason} =
perform_job(SendWelcomeEmail, %{user_id: -1})
end
end
Testing Rules
- Use
perform_job/2 — not perform/1. perform_job validates args and simulates the Oban runtime.
- Use
assert_enqueued/1 — verify jobs were enqueued with correct args.
- Use
Oban.Testing with testing: :manual in test config — jobs stay queued so assert_enqueued and perform_job work.
- Test all return paths — success, retryable error, and cancel.
Job Args Best Practices
# Bad — large data in args (stored as JSON in database)
SendReport.new(%{
user_id: user.id,
report_data: large_data_structure # Don't do this!
})
# Good — store IDs, fetch fresh data in worker
SendReport.new(%{user_id: user.id, report_id: report.id})
# Bad — non-JSON-serializable args
SendEmail.new(%{user: user}) # Structs don't serialize to JSON
# Good — pass IDs, fetch in worker
SendEmail.new(%{user_id: user.id})
Error Handling
@impl Oban.Worker
def perform(%Oban.Job{args: %{"url" => url}, attempt: attempt}) do
case HTTPClient.get(url) do
{:ok, %{status: 200, body: body}} ->
{:ok, process(body)}
{:ok, %{status: 404}} ->
{:cancel, "resource not found at #{url}"}
{:ok, %{status: 429}} ->
{:snooze, retry_delay(attempt)}
{:error, reason} ->
{:error, reason} # Will retry up to max_attempts
end
end
defp retry_delay(attempt), do: attempt * 60 # Exponential-ish backoff
See testing-essentials skill for comprehensive testing patterns.