Replacing Delayed Job and Crono with a single, modern background job solution
Background job processing is critical for Rails applications, handling everything from sending emails to processing data asynchronously. For years, Delayed Job was the go-to solution, often paired with Crono for scheduled tasks. But modern Rails applications deserve modern tools.
This post details how to migrate from Delayed Job + Crono to GoodJob, a multithreaded, Postgres-based ActiveJob backend that consolidates background jobs and scheduled tasks into one elegant solution.
Why Replace Delayed Job?
Delayed Job served us well, but after years in production, its limitations became apparent:
Single-threaded Processing: Delayed Job processes one job at a time per worker process. To handle concurrent jobs, you need multiple worker dynos, increasing costs.
No Built-in Scheduling: Delayed Job handles queued jobs but not recurring tasks. If you need scheduling, you need an extra gem like Crono, adding complexity.
Limited Observability: The web UI (delayed-web) is basic. Debugging failed jobs or understanding queue patterns requires manual database queries.
Database Polling: Delayed Job constantly polls the database for new jobs, creating unnecessary load even when idle.
Separate Worker Processes: Running dedicated worker dynos on Heroku adds cost. Modern solutions can share capacity with web dynos more efficiently.
GoodJob addresses all these issues while remaining simple and Rails-native.
Why GoodJob?
Multithreaded: Process multiple jobs concurrently in a single worker process. Better resource utilization means lower costs.
Built-in Cron: Native support for recurring tasks eliminates the need for Crono. Schedule jobs directly in your Rails configuration.
Postgres-based: Uses your existing database. No additional infrastructure required (unlike Sidekiq’s Redis dependency).
Web-Dashboard: Neat web UI for monitoring jobs, viewing performance metrics, and debugging failures.
Modern ActiveJob Support: First-class support for Rails’ ActiveJob API with features like priorities, retries, and batching.
Efficient Polling: Uses Postgres LISTEN/NOTIFY for near-instant job pickup instead of constant polling.
Production Ready: Battle-tested, actively maintained, and used by companies running millions of jobs.
The Migration Process
Step 1: Add GoodJob Gem
Update your Gemfile to replace Delayed Job and Crono:
# Remove:
# gem 'delayed_job_active_record', '~> 4.1.2'
# gem 'delayed-web', '~> 0.4.9'
# gem 'crono', '~> 2.0'
# Add:
gem 'good_job', '~> 3.23'
If you’re using Heroku autoscaling, also update the autoscaler gem:
# Remove:
# gem 'rails-autoscale-delayed_job', '~> 1.0'
# Add:
gem 'judoscale-good_job', '~> 1.5'
Run bundle install to update dependencies.
Step 2: Configure ActiveJob Queue Adapter
Update your application configuration to use GoodJob:
config/application.rb:
module YourApp
class Application < Rails::Application
# Change from:
# config.active_job.queue_adapter = :delayed_job
# To:
config.active_job.queue_adapter = :good_job
end
end
Step 3: Install GoodJob Migrations
Generate and run GoodJob’s database migrations:
bin/rails good_job:install
bin/rails db:migrate
This creates several tables:
good_jobs- The main job queuegood_job_batches- For batch job supportgood_job_executions- Historical execution recordsgood_job_processes- Worker process trackinggood_job_settings- Configuration storage
The schema includes sophisticated indexes for efficient job processing:
create_table :good_jobs, id: :uuid do |t|
t.text :queue_name
t.integer :priority
t.jsonb :serialized_params
t.datetime :scheduled_at
t.datetime :performed_at
t.datetime :finished_at
t.text :error
t.timestamps
t.uuid :active_job_id
t.text :concurrency_key
t.text :cron_key
# ... additional fields
end
# Critical indexes for performance
add_index :good_jobs, [:queue_name, :scheduled_at],
where: "(finished_at IS NULL)"
add_index :good_jobs, [:priority, :created_at],
order: { priority: "DESC NULLS LAST", created_at: :asc },
where: "finished_at IS NULL"
Step 4: Configure GoodJob
Create a GoodJob initializer for basic configuration:
config/initializers/good_job.rb:
# Preserve job records for debugging and metrics
GoodJob.preserve_job_records = true
# Don't retry unhandled errors automatically
# (Rely on ActiveJob retry logic instead)
GoodJob.retry_on_unhandled_error = false
# Use ApplicationRecord as parent class
GoodJob.active_record_parent_class = 'ApplicationRecord'
Step 5: Migrate Scheduled Tasks from Crono to GoodJob Cron
GoodJob includes built-in cron scheduling. Move your Crono configuration to Rails config.
Before (config/cronotab.rb):
class ReportsDailyJob
def perform
ReportsMailer.send_daily
end
end
Crono.perform(ReportsDailyJob).every 1.day, at: '05:00'
After (config/application.rb):
module YourApp
class Application < Rails::Application
# ... other config
config.good_job.cron = {
reports_mailer_daily_job: {
class: 'ReportsMailerJob',
cron: '0 1 * * *', # At 01:00 (UTC) every day
args: ['daily'],
set: { queue: 'default' },
description: 'Daily reports mailer job'
},
reports_mailer_monthly_job: {
class: 'ReportsMailerJob',
cron: '30 1 1 * *', # At 01:30 on the 1st of each month
args: ['monthly'],
set: { queue: 'default' },
description: 'Monthly reports mailer job'
},
reports_mailer_yearly_job: {
class: 'ReportsMailerJob',
cron: '45 1 1 1 *', # At 01:45 on January 1st
args: ['yearly'],
set: { queue: 'default' },
description: 'Yearly reports mailer job'
},
cleanup_incomplete_data_acquisitions_job: {
class: 'RakeCleanupIncompleteJob',
cron: '0 2 * * *', # At 02:00 every day
args: [],
set: { queue: 'default' },
description: 'Daily cleanup job'
}
}
end
end
Cron syntax is standard Unix cron format:
0 1 * * *= Every day at 01:00*/15 * * * *= Every 15 minutes0 0 * * 0= Every Sunday at midnight
Step 6: Enable Cron in Environments
Enable cron scheduling in your environment configurations:
config/environments/development.rb:
Rails.application.configure do
# Enable cron in development
config.good_job.enable_cron = true
end
config/environments/production.rb:
Rails.application.configure do
# Only enable cron on the first worker dyno to prevent duplicate execution
config.good_job.enable_cron = ENV['DYNO'] == 'worker.1'
# Alternatively, enable on all workers (GoodJob deduplicates via cron_key):
# config.good_job.enable_cron = true
# Or control via environment variable:
# config.good_job.enable_cron = ENV['GOOD_JOB_ENABLE_CRON'] == 'true'
end
For Heroku, using ENV['DYNO'] == 'worker.1' ensures only one worker runs cron jobs, preventing duplicates.
Step 7: Migrate Existing Queued Jobs
If you have pending Delayed Jobs when migrating, create a data migration:
db/migrate/TIMESTAMP_migrate_delayed_jobs_to_good_job.rb:
class MigrateDelayedJobsToGoodJob < ActiveRecord::Migration[7.0]
def up
# Only migrate jobs that haven't been attempted yet
Delayed::Job.where('attempts = 0').find_each do |dj|
# Deserialize the Delayed Job handler
handler = YAML.safe_load(
dj.handler,
permitted_classes: [
ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper,
# Add your job classes here
]
)
job_class = handler.job_data['job_class']
job_id = handler.job_data['job_id']
# Create equivalent GoodJob record
GoodJob::Job.create!(
queue_name: dj.queue || 'default',
priority: dj.priority,
serialized_params: handler.job_data,
scheduled_at: dj.run_at,
created_at: dj.created_at,
updated_at: Time.current,
active_job_id: job_id,
job_class: job_class
)
end
# Optionally remove migrated Delayed Jobs
# Delayed::Job.where('attempts = 0').delete_all
end
def down
# Rollback not practical - jobs may have already executed
raise ActiveRecord::IrreversibleMigration
end
end
Note: This migration is optional. You can also just let existing Delayed Jobs fail and requeue them, or process them before switching.
Step 8: Update Procfile
Replace Delayed Job and Crono worker processes with GoodJob:
Before (Procfile):
web: bundle exec puma -C config/puma.rb
worker: bin/rails jobs:work
cron: bundle exec crono RAILS_ENV=production
After (Procfile):
web: bundle exec puma -C config/puma.rb
worker: bundle exec good_job start
Procfile.dev (for local development):
postgres: /path/to/postgres
web: PORT=3000 bin/rails server
worker: bundle exec good_job start
Step 9: Mount the Dashboard
GoodJob includes a useful web dashboard. Mount it in your routes:
config/routes.rb:
Rails.application.routes.draw do
# Restrict to admin users
authenticated :user, ->(user) { user.admin? } do
mount GoodJob::Engine, at: 'admin/good_job'
end
# Or use your authorization logic:
# constraints ->(request) { request.env['warden'].user&.admin? } do
# mount GoodJob::Engine, at: 'admin/good_job'
# end
end
Access the dashboard at /admin/good_job to:
- View all jobs (pending, running, succeeded, failed)
- See execution history and performance metrics
- Retry or delete failed jobs
- Monitor cron job schedules
- Inspect individual job parameters and errors
Step 10: Remove Old Dependencies
After verifying everything works, clean up:
Remove old gems:
# Delete from Gemfile:
# gem 'delayed_job_active_record'
# gem 'delayed-web'
# gem 'crono'
Delete initializers:
rm config/initializers/delayed_job.rb
rm config/initializers/delayed_web.rb
rm config/cronotab.rb
Delete bin scripts:
rm bin/delayed_job
Remove routes:
# Delete from routes.rb:
# mount Delayed::Web::Engine, at: '/jobs'
# mount Crono::Engine, at: '/crono'
Run bundle install to finalize the cleanup.
Step 11: Deploy
Deploy incrementally to minimize risk:
- Deploy to staging first
- Verify cron jobs run on schedule
- Monitor the GoodJob dashboard for errors
- Test job processing under load
- Deploy to production during low-traffic period
- Monitor for 24-48 hours before cleanup
Advanced Configuration
Concurrency and Thread Pool
Control how many jobs GoodJob processes concurrently:
# Via environment variable
GOOD_JOB_MAX_THREADS=10 bundle exec good_job start
# Or in code
GoodJob.configuration.max_threads = 10
Queue-Specific Workers
Run dedicated workers for specific queues:
# Process only high-priority queue
bundle exec good_job start --queues="high_priority"
# Process multiple specific queues
bundle exec good_job start --queues="default,low_priority"
# Exclude certain queues
bundle exec good_job start --queues="-low_priority"
Polling vs. LISTEN/NOTIFY
GoodJob supports two execution modes:
# Async mode (LISTEN/NOTIFY, near-instant job pickup)
bundle exec good_job start --probe-mode=async
# Polling mode (fallback for databases without LISTEN/NOTIFY)
bundle exec good_job start --probe-mode=polling --poll-interval=5
Async mode is default and recommended for Postgres.
Job Retention
Control how long job records are kept:
# config/initializers/good_job.rb
# Keep all jobs indefinitely (good for debugging)
GoodJob.preserve_job_records = true
# Auto-cleanup finished jobs
GoodJob.cleanup_preserved_jobs_before_seconds_ago = 7.days.to_i
# Or use a periodic cleanup job
config.good_job.cron = {
cleanup_job: {
class: 'GoodJob::CleanupJob',
cron: '0 3 * * *', # 3 AM daily
description: 'Clean up old job records'
}
}
Inline Execution in Tests
Run jobs synchronously in tests:
# config/environments/test.rb
config.active_job.queue_adapter = :good_job
# test/test_helper.rb
GoodJob.configuration.execution_mode = :inline
Performance Comparison
Our production metrics after migration:
Before (Delayed Job + Crono):
- 2 worker dynos (1X, $50/month total)
- Average job latency: 15-30 seconds
- Job throughput: ~100 jobs/hour per dyno
- Cron scheduling: Separate Crono dyno required
After (GoodJob):
- 1 worker dyno (1X, $25/month)
- Average job latency: <5 seconds
- Job throughput: ~400 jobs/hour per dyno
- Cron scheduling: Built-in, no extra cost
Cost Savings: 50% reduction in worker dyno costs
Performance: 4x throughput improvement, 5x latency reduction
Common Gotchas
1. Cron Jobs Running Multiple Times
If you enable cron on multiple workers without proper configuration, jobs may run multiple times.
Solution: Use ENV['DYNO'] == 'worker.1' or rely on GoodJob’s built-in deduplication via cron_key.
2. Job Arguments Not Serializing
GoodJob uses ActiveJob’s serialization. Complex objects may fail.
Solution: Only pass serializable arguments (strings, numbers, GlobalIDs):
# Bad
MyJob.perform_later(user_object)
# Good
MyJob.perform_later(user_object.to_global_id)
3. Database Connection Pool Exhaustion
Multithreading can exhaust your connection pool.
Solution: Increase pool size in database.yml:
production:
pool: 10
4. Cron Jobs Not Running
Verify cron is enabled in your environment config.
Debug:
# In rails console
GoodJob.configuration.enable_cron
# => Should return true
# Check cron configuration
GoodJob.configuration.cron
# => Should show your scheduled jobs
Migration Checklist
- Add
good_jobgem and remove Delayed Job gems - Update
active_job.queue_adapterto:good_job - Run GoodJob installation and migrations
- Configure GoodJob initializer
- Migrate Crono tasks to
config.good_job.cron - Enable cron in environment configs
- Migrate existing queued jobs (optional)
- Update Procfile to use
good_job start - Mount GoodJob dashboard in routes
- Test in staging environment
- Monitor job execution and cron schedules
- Deploy to production
- Remove old Delayed Job/Crono code
- Update documentation
Conclusion
Migrating from Delayed Job to GoodJob modernizes your background job infrastructure with minimal code changes. By consolidating job processing and scheduling into a single, efficient tool, you reduce operational complexity, improve performance, and save money.
GoodJob’s use of Postgres eliminates external dependencies while its multithreaded architecture maximizes resource utilization. The excellent dashboard provides visibility into job execution that Delayed Job never offered.
For Rails applications using Postgres, GoodJob is a great choice for background jobs in 2023 and beyond. The migration is straightforward, the benefits are immediate, and the developer experience is outstanding.