Background jobs with Active Job

Ruby on Rails · Advanced · 14 min

Emails, exports, webhooks and API calls belong in jobs so requests stay fast and failures can be retried.

Active Job is Rails' common interface for background jobs, and Solid Queue, the Rails 8 default, stores jobs in the database, so no Redis is required. Sidekiq and GoodJob are popular alternative backends.

Generate a job with bin/rails generate job ExportReport:

class ExportReportJob < ApplicationJob
  queue_as :default
  def perform(user, month) ... end
end

Enqueue with ExportReportJob.perform_later(user, "2025-06"). Arguments are serialised; Active Record objects are passed as GlobalID references and re-fetched when the job runs, so pass records rather than huge hashes. Schedule with set(wait: 10.minutes) or set(wait_until: ...). Mailers have deliver_later, which uses Active Job.

Handling failure:

  • retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5 retries transient errors with backoff.
  • discard_on ActiveJob::DeserializationError drops jobs whose records were deleted.
  • Make jobs idempotent, since a job can run more than once after a crash or retry.

Enqueue jobs after the surrounding transaction commits (use after_commit callbacks), or a worker may run before the data exists.

Run workers with bin/jobs; in production Solid Queue can run inside Puma (SOLID_QUEUE_IN_PUMA) or as its own process. Recurring tasks are declared in config/recurring.yml. Mission Control Jobs adds a web dashboard for inspecting and retrying jobs. In tests, perform_enqueued_jobs runs them inline.

Example

class SyncInvoiceJob < ApplicationJob
  queue_as :billing
  retry_on Net::OpenTimeout, Faraday::ServerError, wait: :polynomially_longer, attempts: 5
  discard_on ActiveJob::DeserializationError       # invoice was deleted

  def perform(invoice)                              # re-fetched via GlobalID
    return if invoice.synced_at.present?            # idempotent
    remote_id = BillingApi.push(invoice.as_json)
    invoice.update!(remote_id:, synced_at: Time.current)
  end
end

class Invoice < ApplicationRecord
  after_create_commit -> { SyncInvoiceJob.perform_later(self) }
end

SyncInvoiceJob.set(wait: 10.minutes).perform_later(invoice)

# config/recurring.yml (Solid Queue)
# production:
#   nightly_cleanup:
#     class: CleanupJob
#     schedule: every day at 3am

# $ bin/jobs     # start Solid Queue workers locally

Remember

  • perform_later enqueues; records are passed as GlobalIDs and reloaded
  • retry_on and discard_on declare failure handling; keep jobs idempotent
  • Solid Queue (default) stores jobs in the database; recurring tasks go in recurring.yml

Watch out for

  • Enqueuing a job from after_save inside a transaction, so the worker can't find the uncommitted record.

Check yourself

  1. How does Active Job pass an Active Record object argument to a job?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: guides.rubyonrails.org/active_job_basics.html