Active Job Continuations in Rails 8.1: What They Actually Solve

Rails 8.1 shipped on 22 October 2025, and Rails 8.1.3, released on 24 March 2026, is the current stable release as of writing (April 2026). After a few months of using Active Job Continuations on a couple of client projects, I want to write down what I think is actually worth your attention.

This is the headline new feature in Active Job since Rails 8.0 introduced the Solid trifecta, and it solves a problem most teams running a Rails app in production have hit at least once: a long running job gets killed mid flight by a deploy or a worker restart, and the next time it runs it starts over from the beginning.

Continuations let you break a job into steps and have the job resume from the last completed step rather than from scratch. That's the whole pitch. But the API has more depth than that, and a few sharp edges worth knowing before you reach for it.

The problem in concrete terms

Imagine an import job that processes 80,000 rows from an uploaded CSV. It's been running for twelve minutes, has processed 51,000 rows, and at that moment you push a deploy. Kamal sends SIGTERM to the worker container. By default Kamal gives that container thirty seconds to shut down. Your job doesn't finish in thirty seconds, so it gets killed. Depending on the queue backend, the job is retried or recovered later, and without explicit progress tracking, it starts from row 1.

Best case, you've burned compute time. Worst case, you've duplicated 51,000 records because the job wasn't fully idempotent, and now you're writing a clean-up script.

The traditional answer has been to roll your own state machine: a JobProgress model, a last_processed_id column, and code that filters records on resume. It works, but every implementation has subtle bugs around partial progress, and onboarding any new developer means walking them through your bespoke pattern.

Rails 8.1's answer is to bake the pattern into Active Job itself.

The basic API

Include ActiveJob::Continuable and define your work as a series of step calls inside perform:

class ProcessImportJob < ApplicationJob
  include ActiveJob::Continuable

  def perform(import_id)
	# This always runs, even on resume.
	@import = Import.find(import_id)

	step :validate do
	  @import.validate!
	end

	step :process_records do |step|
	  @import.records.find_each(start: step.cursor) do |record|
		record.process
		step.advance! from: record.id
	  end
	end

	step :finalize do
	  @import.finalize!
	end
  end
end

A few things to notice:

  • Code outside step blocks runs every time the job starts, including resumes. Anything expensive or non-idempotent has to live inside a step.
  • Steps run sequentially. If the job is interrupted partway through process_records, the next execution skips validate (already completed) and resumes process_records from the last cursor value.
  • The cursor can be any object Active Job can serialise, for example, an integer ID, an array for nested iteration, or a string token from an external API.

You can also pass a method name instead of a block, which keeps the perform body readable when individual steps get long.

Cursors, checkpoints, and what "interruptible" actually means

Two concepts to keep separate.

A cursor is your position within a step. step.advance! moves the cursor forward by calling succ on the current value; with from: record.id, it advances from that supplied value, which is useful when iterating over records where IDs may not be contiguous. For arbitrary cursor objects, use step.set!(new_value).

A checkpoint is a moment at which the job checks whether the queue adapter is shutting down. At a checkpoint, the job calls queue_adapter.stopping?. If that returns true, the job raises ActiveJob::Continuation::Interrupt, the framework serialises progress, and the job is re-enqueued.

Checkpoints are created automatically before each step (except the first execution of the first step) and whenever you call set!, advance!, or checkpoint!. The last one is useful if you want to allow interruption inside a step but don't actually need to update a cursor, for example, a destroy loop where finished records simply no longer exist:

step :destroy_records do |step|
  records.find_each do |record|
	record.destroy!
	step.checkpoint!
  end
end

Critically, the framework does not interrupt your job at arbitrary points, it only interrupts at checkpoints. The Rails docs are explicit about this: jobs need to checkpoint more frequently than the shutdown timeout to actually be interrupted gracefully. If your inner loop has a five-minute step body with no checkpoints, a thirty-second SIGTERM grace period won't save you.

Adapter support: read this carefully

This is where I'd urge you not to take social media posts at face value.

The continuation mechanism only works if your queue adapter implements stopping?. By default it returns false, so without adapter support continuations are effectively just structured code with no graceful resumption behaviour.

As of Rails 8.1, the test adapter and Rails' Sidekiq adapter implement stopping?. Solid Queue added Active Job Continuations support in v1.2.0, so check your solid_queue version before assuming graceful interruption works end to end. Delayed Job and Resque do not appear to have native support for this shutdown signal and would need adapter/lifecycle-hook work.

Practically: if you're using Active Job through the Rails Sidekiq adapter on Rails 8.1, you have the required stopping? support, still test your deployment shutdown path. If you're on Solid Queue, confirm you're on v1.2.0 or later. If you're on Resque or Delayed Job, the feature won't interrupt jobs gracefully, though continuations will still cleanly retry on errors after progress is made, so they're not entirely useless on those adapters.

Configuration knobs

Three class-level settings worth knowing:

class ProcessImportJob < ApplicationJob
  include ActiveJob::Continuable

  self.max_resumptions = 50
  self.resume_options = { wait: 2.seconds, queue: :imports }
  self.resume_errors_after_advancing = true
end

max_resumptions caps how many times a job can resume, useful as a safety net against situations where a step keeps failing partway through. Defaults to nil, meaning unlimited.

resume_options are passed to retry_job when the framework re-enqueues the job. The default is { wait: 5.seconds }.

resume_errors_after_advancing is the most interesting one. By default (true), if a step raises an error after the cursor has moved forward, the framework treats this as "we've made real progress, let's not throw it away" and re-enqueues the job rather than letting the error propagate. Disable it if you'd rather the error surface immediately and your existing retry strategy take over.

When I'd reach for this, when I wouldn't

Use continuations when:

  • The job runs for longer than your shutdown grace period (so anything multi-minute, in practice).
  • The work is naturally divisible into stages and the cost of redoing earlier stages is non-trivial, LLM calls, large file processing, external API requests with rate limits.
  • Idempotency is hard to retrofit, and continuations can reduce the amount of repeated work, but you still need to design side effects carefully.

Skip them when:

  • Jobs finish in under a few seconds. The bookkeeping isn't worth it.
  • Your job is already idempotent and cheap to restart. Just let it restart.
  • You need fan-out parallelism inside a step. Continuations are sequential by design, they aren't a replacement for batched job patterns, and trying to use them as one will hurt.

There's also a middle ground I'd watch out for: jobs where the work could be split into steps but doing so meaningfully complicates the code. The Rails docs describe continuations as "a sharp knife", they require manual checkpoint and cursor management, and the price of resumability is being deliberate about where progress lives. If your job already works fine and rarely gets interrupted, leave it.

A few things that have caught me out

Code outside steps re-runs on resume. If you do something like @import = Import.find(import_id) outside any step, that's fine, it's read-only. But anything with side effects (sending a notification, writing a status row, queuing another job) will execute every time the job resumes. Move side-effecting setup into its own step.

Cursors must be serialisable. The cursor lands in the job's serialised payload. Stick to primitives, arrays, and objects with proper Active Job serialisers. A raw ActiveRecord::Relation won't work.

isolated: true is your friend for big steps. If a step is too long to checkpoint within a single grace period, say, a five-minute external API call, you can declare it step :slow_one, isolated: true and the framework will run it in its own job execution. This guarantees that earlier progress is serialised back to the queue before the slow step begins. It protects earlier completed progress, but the isolated step itself still needs checkpoints or idempotency if partial work inside it matters.

Pair with structured event reporting. Rails 8.1 also added Rails.event.notify, which pairs nicely with continuations for per-step observability, emitting an event at each step boundary or cursor advance gives you a built-in narrative of where a job is and where it stalled.

Bottom line

Continuations are a real, well-designed addition to Active Job and they remove a pattern that many Rails teams have had to hand-roll for themselves. They aren't a magic resilience layer, they require deliberate use, your adapter has to implement stopping? for graceful interruption, and you still need to think about idempotency. But for the long-running import, sync, or pipeline jobs where you've previously hand-rolled progress tracking, they're worth adopting.

If you're on Rails 8.1.3 with the Rails Sidekiq adapter, you can start using them today. If you're on Solid Queue, confirm you're on v1.2.0 or later. And if you're still on Rails 7.x and weighing up the upgrade, this is one more reasonable item on the "why bother" list.


If you're running a Rails app and the upgrade-and-modernise list keeps slipping, that's the kind of thing I help with on a fractional basis. Get in touch if it would be useful to talk it through.

Looking for a fractional Rails engineer or CTO?

I take on a limited number of part-time clients.

Get in touch