1. Home
  2. Work
  3. Contact
  4. Blog

Im Obstgarten 7
8596 Scherzingen
Switzerland


(CET/CEST, Mo-Fr, 09:00 - 18:00)

IMEOS on GitHub
Rails

Migrating from Delayed Job to GoodJob

19 Aug 2023
11 minutes read

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 queue
  • good_job_batches - For batch job support
  • good_job_executions - Historical execution records
  • good_job_processes - Worker process tracking
  • good_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 minutes
  • 0 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:

  1. Deploy to staging first
  2. Verify cron jobs run on schedule
  3. Monitor the GoodJob dashboard for errors
  4. Test job processing under load
  5. Deploy to production during low-traffic period
  6. 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_job gem and remove Delayed Job gems
  • Update active_job.queue_adapter to :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.

Further Reading

  • GoodJob Documentation
  • GoodJob Cron Syntax
  • ActiveJob Guides
  • Choosing a Background Job Library

  • Privacy
  • Imprint