BLUF (Bottom Line Up Front): delayed_job relies on an SQL database, causing severe deadlocks and high queue latency at scale. Sidekiq uses Redis, offering massive concurrency. To migrate safely, you must run both processors simultaneously during the transition and use a script to re-enqueue stranded delayed_job payloads into Sidekiq.
Phase 1: The Relational Bottleneck
delayed_job uses row-level locking in PostgreSQL or MySQL. When hundreds of jobs are processed concurrently, the database spends more CPU time managing locks than executing queries.
Synthetic Engineering Context: High Queue Latency
In your APM (like New Relic), you notice background jobs are taking 15 minutes to start, even though the actual execution time is 200ms.
# Database log showing lock contention
FATAL: terminating connection due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
STATEMENT: UPDATE "delayed_jobs" SET locked_at = '2026-04-23 10:00:00', locked_by = 'host:worker-1' WHERE id = 15432
Phase 2: The Migration Strategy
A hard cutover will result in lost jobs. You must adopt a dual-boot approach.
Execution: Step 1 - Dual Configuration
Configure ActiveJob to push new jobs to Sidekiq, but keep the delayed_job worker running to drain the old queue.
# config/application.rb
# Direct all NEW jobs to Sidekiq
config.active_job.queue_adapter = :sidekiq
Deploy order matters. Ship the adapter switch first and let the existing delayed_job workers keep draining the old table. Do not stop them until Delayed::Job.where(failed_at: nil).count reaches zero or you run the re-enqueue script below. Running the script while delayed_job workers are still polling the table risks double execution: the script re-enqueues a job into Sidekiq at the same moment a DJ worker locks and runs it. Stop the DJ workers first, then migrate the stragglers.
Execution: Step 2 - The Re-enqueue Script (PoC)
If you have jobs scheduled for weeks in the future (e.g., subscription reminders), you cannot wait for the delayed_job queue to drain naturally. You must extract them from the database and push them to Redis persistency.
# lib/tasks/migrate_jobs.rake
namespace :jobs do
desc "Migrate scheduled delayed_jobs to Sidekiq"
task delayed_to_sidekiq: :environment do
Delayed::Job.find_each do |dj|
# Parse the YAML payload generated by DelayedJob
payload = YAML.load(dj.handler)
# Extract the original ActiveJob class and arguments
job_class = payload.job_data['job_class'].constantize
args = payload.job_data['arguments']
if dj.run_at > Time.current
# Schedule it in Sidekiq
job_class.set(wait_until: dj.run_at).perform_later(*args)
else
# Enqueue immediately
job_class.perform_later(*args)
end
# Delete the migrated record
dj.destroy!
end
end
end
Execution: Step 3 - Handling Non-ActiveJob Payloads
The script above only works for jobs enqueued through ActiveJob. Legacy codebases predating Rails 4.2 often call object.delay.some_method or handle_asynchronously, which serializes a Delayed::PerformableMethod instead. Two traps here:
- Psych 4 breaks the YAML parsing. On Ruby 3.1+ (Psych 4),
YAML.loadrefuses to deserialize arbitrary classes and raisesPsych::DisallowedClass. For the migration script you needYAML.unsafe_load(dj.handler), orDelayed::Jobdeserialization helpers if the gem is still loaded. PerformableMethodhas no Sidekiq equivalent. A handler like--- !ruby/object:Delayed::PerformableMethodwraps an object, a method name, and args. You must rewrite each of these call sites as an explicit ActiveJob class before the migration, otherwise there is nothing to re-enqueue them into.
Grep for the legacy API to size this work before you commit to a cutover date:
grep -rn "\.delay\.\|handle_asynchronously" app/ lib/ | grep -v spec
Retry Semantics Are Not the Same
delayed_job retries 25 times with an (attempts ** 4) + 5 backoff and then leaves the row in the table with failed_at set. Sidekiq also retries ~25 times over about 21 days, but then moves the job to the dead set, which is capped (10,000 jobs by default) and pruned after 6 months. Two consequences:
- Any alerting built on
Delayed::Job.where.not(failed_at: nil).countmust be replaced with monitoring ofSidekiq::DeadSet.new.sizeand queue latency (Sidekiq::Queue.new("default").latency). - Jobs that are not idempotent and relied on
max_attempts: 1in delayed_job needsidekiq_options retry: 0explicitly, otherwise Sidekiq will happily re-run a failed charge 25 times.
If the old code depended on delayed_job’s implicit table-level uniqueness checks, add sidekiq-unique-jobs or an application-level guard; Sidekiq itself deduplicates nothing.
Phase 3: Next Steps & Risk Mitigation
Redis is an in-memory data store. If you do not configure Redis persistency (AOF or RDB backups), a server restart will wipe out all scheduled Sidekiq jobs. You must ensure your Redis infrastructure is hardened before running the migration script. For a subscription platform, losing the scheduled-jobs set means losing every future renewal reminder, so verify appendonly yes in redis.conf and test a restart on staging before cutover.
Post-migration, watch three metrics for the first week: queue latency per queue, the size of the retry set, and Redis memory usage (scheduled jobs live in a sorted set and large argument payloads add up; pass IDs, not serialized objects). Keep the empty delayed_jobs table around for a release or two: it is your rollback target if a hidden .delay call site surfaces in production.
Need Help Stabilizing Your Legacy App? Background job migrations carry a high risk of dropping critical business events (like billing emails). Our team at USEO executes zero-downtime infrastructure migrations.