Active Job Continuations in Rails 8.1

JetThoughts blog cover for Active Job Continuations in Rails 8.1 - minimalist dark design with Ruby-to-purple gradient headline

Your background jobs lie to you.

You tell yourself they’re idempotent. You tell yourself retries are safe. Then a Kamal deploy kicks off at 2pm, a 40-minute import job gets 30 seconds to shut down, and the whole thing restarts from row one on the next container. Users sit there refreshing. Postgres re-runs the same 40-minute query. The bill at the end of the month doesn’t ask why.

Rails 8.1 fixes this with a first-class API called ActiveJob::Continuable. Include it in a job, define steps, and if the process dies mid-run, the retry picks up where it left off instead of starting over.

The wiring is straightforward. The trade-offs aren’t. Both below.

The problem Rails 8.1 just solved #

Before Active Job Continuations, a “safe” long-running job looked like this:

  1. Track your own progress in a database row.
  2. Write custom resumption logic.
  3. Hope your resumption logic handles the step where the interrupt happened.
  4. Get paged anyway, because you forgot one edge case.

We rewrote that progress-tracking pattern on three rescue projects in 2024 alone - same Postgres job_progress table, same 3am page after a deploy interrupted a payroll batch, same edge case where the resumption logic missed the step that was in flight when the SIGTERM landed.

In October 2025 Rails 8.1 shipped ActiveJob::Continuable. Jobs that include it can define discrete steps. If the job is interrupted mid-run, previously completed steps are skipped on retry. In-progress steps resume from the last recorded cursor. Custom progress tables and manual resume logic go away.

The API in 30 seconds #

class ProcessOrderBatchJob < ApplicationJob
  include ActiveJob::Continuable

  def perform(batch_id)
    step :fetch_orders do
      @orders = Order.where(batch_id: batch_id).to_a
    end

    step :process_orders do |step|
      @orders.drop(step.cursor || 0).each_with_index do |order, index|
        order.process!
        step.set! cursor: index
      end
    end

    step :notify_finance do
      FinanceMailer.batch_complete(batch_id).deliver_now
    end
  end
end

Three steps. If the process dies between “process_orders” and “notify_finance”, the retry skips the first two steps entirely and jumps straight to the mailer. If the process dies halfway through “process_orders”, the retry resumes at the exact order index where the cursor stopped.

That’s the whole API. No surprises.

Why this matters more than it sounds #

Most Rails teams will scroll past this feature. “We already use Sidekiq retries. We’re fine.”

You’re not fine. Here’s why.

1. Kamal’s 30-Second Shutdown Is Real #

Kamal - the default Rails 8 deployment tool - gives job-running containers 30 seconds to exit gracefully on deploy. Not 30 minutes. Thirty seconds. If your nightly report job is 20 minutes in when the deploy hits, it’s dead. The standard Sidekiq retry starts it from the beginning. You’ve just done the work twice and delayed the deploy while the second run catches up.

Continuations turn that restart into a resume. The deploy still kills the worker. The retry still fires. But the work already done stays done. (For automating those deploys, see our guide on Kamal 2 with GitHub Actions .)

2. The Server Cost Is Quiet but Real #

Every restarted job does the work twice. If your 18-minute nightly report gets killed at minute 17 by a deploy, the retry runs all 18 minutes again - you paid for 35 minutes of compute to get 18 minutes of output. That cost sits in the bill as “background workers,” which most teams never dig into.

The math is blunt: if you deploy daily and you run any job longer than 10 minutes, you’re paying for restarts. The cost scales linearly with deploy frequency and job duration. Continuations stop you from paying.

3. Your Idempotency Isn’t What You Think #

Ask your team: “Are all our long-running jobs idempotent?” Watch the confidence drop the longer the list gets. On a fintech rescue last quarter the nightly reconciliation re-sent 1,400 ACH confirmation emails after a Kamal deploy hit minute 22 of a 25-minute job. Idempotent on the database side, painful on the inbox side.

Continuations let you stop pretending. Mark the risky step as a checkpoint. If it finished, it stays finished.

Adding continuations to an existing job #

Here’s a job you might already have, before and after.

Before (Rails 7 / Rails 8.0):

class SyncShopifyOrdersJob < ApplicationJob
  def perform(shop_id)
    shop = Shop.find(shop_id)
    orders = ShopifyAPI::Order.fetch_all(shop)

    orders.each do |order|
      LocalOrder.upsert_from(order)
    end

    shop.update!(last_synced_at: Time.current)
    SyncCompleteMailer.notify(shop).deliver_now
  end
end

A deploy halfway through the orders.each loop means: API re-fetch, re-upsert every order, resend the email. Total waste: the entire loop plus a duplicate email.

After (Rails 8.1 with Continuable):

class SyncShopifyOrdersJob < ApplicationJob
  include ActiveJob::Continuable

  def perform(shop_id)
    @shop = Shop.find(shop_id)

    step :fetch_orders do
      @orders = ShopifyAPI::Order.fetch_all(@shop)
    end

    step :upsert_orders do |step|
      @orders.drop(step.cursor || 0).each_with_index do |order, index|
        LocalOrder.upsert_from(order)
        step.set! cursor: index
      end
    end

    step :mark_synced do
      @shop.update!(last_synced_at: Time.current)
    end

    step :notify do
      SyncCompleteMailer.notify(@shop).deliver_now
    end
  end
end

Same logic. Same outputs. One include and four step blocks. On interruption, the retry resumes at whichever step was running and - inside upsert_orders - at whichever order index had just been processed.

The gotcha: the @orders ivar isn’t persisted across interruptions. If the job dies and resumes in a new process, @orders is nil. That’s why fetch_orders exists as its own step - but when the second step resumes, it re-runs fetch_orders first because ivars don’t survive. For most jobs this is fine. For expensive fetches, store the IDs you need in a short-lived cache or a dedicated table and pull them back at the top of each resumable step.

The Kamal 30-second trap, fixed properly #

Here’s the specific production pattern that makes this feature pay for itself.

class NightlyReportJob < ApplicationJob
  include ActiveJob::Continuable

  def perform(report_date)
    step :aggregate_sales do
      Aggregator.build_sales_snapshot(report_date)
    end

    step :aggregate_refunds do
      Aggregator.build_refund_snapshot(report_date)
    end

    step :render_pdf do
      Report.render(report_date)
    end

    step :email_stakeholders do
      ReportMailer.nightly(report_date).deliver_now
    end
  end
end

Four expensive steps. Total runtime: ~18 minutes. Kamal deploy window: 30 seconds.

Before continuations, a deploy during render_pdf meant the retry re-runs both aggregation steps - another 12 minutes of wasted Postgres time. After continuations, the retry skips straight to render_pdf. The deploy cost drops from 18 minutes of duplicated work to zero.

When NOT to use continuations #

Like every powerful feature, this one has wrong uses.

  • Short jobs don’t need it. If your job finishes in under 30 seconds, the Kamal shutdown window is already generous. Adding step blocks just adds noise.
  • Strictly ordered side-effect chains are dangerous. If step 2 sends an email and step 3 charges a card, a retry that “skips” step 2 is wrong if the email didn’t actually reach the user. Steps guarantee completion, not delivery. Use idempotent side effects inside each step.
  • Your adapter has to support it. Solid Queue is the reference adapter that ships with Rails 8.1 Continuable support. Other adapters (Sidekiq, GoodJob) implement the resume-from-cursor handshake differently or not at all; verify your adapter cooperates with the Continuation API before you rely on it.
  • Cursors aren’t magic. If your step iterates over a mutating collection (a query that returns different rows each run), the cursor won’t save you. Freeze the collection in its own fetch step and iterate over a stable list.

Migration path for existing apps #

If you’re on Rails 8.0 today, the migration is two steps.

Step 1: Upgrade to Rails 8.1.3 or later. The current stable release (as of March 24, 2026) is Rails 8.1.3 and Rails 8.0.5 for maintenance. Continuations require Rails 8.1.

Step 2: Add include ActiveJob::Continuable to jobs that run longer than ~1 minute. Sort by impact: the longest-running jobs first. Add steps around the natural phase boundaries of the job. Run in staging with a simulated SIGTERM to confirm the resume path works.

Don’t refactor every job on day one. Do the nightly batch, the nightly reconciliation, the bulk import, the LLM embedding pipeline. Those four cover 80% of the pain for most teams.

What you actually buy with this #

The compute savings get the headline, but the clarity is what we’ll keep using Continuable for. Splitting a job into named steps forces you to write down where it can resume, and that documentation outlives any single deploy. The first time we paired a junior engineer on a Continuable refactor, the documentation it produced was worth more than the resume safety it added.

On rescue audits this quarter, we’re flagging long-running jobs without Continuable the same way we currently flag missing N+1 includes. If you want to test it before you trust it, run a kill -TERM against a Solid Queue worker mid-job in staging and watch the cursor pick up where it stopped.

Running long jobs on Solid Queue or Sidekiq? #

We audit shutdown signal handling, queue isolation, and resume safety on production Rails apps. One senior Rails engineer reads your job code for two hours and writes you a one-page resume-safety plan covering which jobs need Continuable, which can stay simple, and where your Kamal deploy is silently restarting work. Free for funded teams under 25 engineers.

Book the 30-minute call

Related reading on this blog: our Rails performance optimization patterns for 2026 covers YJIT, query allocation, and Redis caching - the companion performance moves you want to make while you’re upgrading to Rails 8.1. And if you’re still on DelayedJob, our Solid Queue migration guide walks through the move.


Further reading: