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
endEnqueue 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: 5retries transient errors with backoff.discard_on ActiveJob::DeserializationErrordrops 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 locallyRemember
- 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.